diff --git a/.gitignore b/.gitignore index e0c1471..6c86e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ .pytest_cache/ *.egg .env +.venv/ diff --git a/tasksmd_sync/sync.py b/tasksmd_sync/sync.py index 689f4ef..def8ec4 100644 --- a/tasksmd_sync/sync.py +++ b/tasksmd_sync/sync.py @@ -407,7 +407,7 @@ def _needs_update(task: Task, board_item: ProjectItem) -> bool: board_item.assignee, ) return True - if task.labels and sorted(task.labels) != sorted(board_item.labels): + if sorted(task.labels) != sorted(board_item.labels): logger.debug( " [DIFF] '%s' labels: %r != %r", task.title, @@ -458,21 +458,25 @@ def _apply_task_fields( logger.warning( "Could not resolve GitHub user '%s' for assignee", task.assignee ) - if task.labels and sorted(task.labels) != sorted(board_item.labels): + if sorted(task.labels) != sorted(board_item.labels): # We need repo owner/name to resolve label IDs owner = board_item.repo_owner or client.org name = board_item.repo_name if name: - label_ids = client.resolve_label_ids(owner, name, task.labels) - if label_ids: - client.set_issue_labels(board_item.content_id, label_ids) + if task.labels: + label_ids = client.resolve_label_ids(owner, name, task.labels) + if label_ids: + client.set_issue_labels(board_item.content_id, label_ids) + else: + logger.debug( + "Could not resolve label IDs for %r in %s/%s", + task.labels, + owner, + name, + ) else: - logger.debug( - "Could not resolve label IDs for %r in %s/%s", - task.labels, - owner, - name, - ) + # Task has no labels — clear all labels from the issue + client.set_issue_labels(board_item.content_id, []) else: logger.debug( "Cannot resolve labels for '%s': no repository information found", diff --git a/tests/test_execute_sync.py b/tests/test_execute_sync.py index b750cc6..f481c19 100644 --- a/tests/test_execute_sync.py +++ b/tests/test_execute_sync.py @@ -595,6 +595,32 @@ def test_unresolvable_labels_does_not_crash(self): assert result.updated == 1 client.set_issue_labels.assert_not_called() + def test_remove_all_labels_from_issue(self): + """Labels should be cleared when task has no labels but issue does.""" + board = [ + _make_board_item( + "PVTI_1", + title="Task", + status="Todo", + content_type="Issue", + content_id="I_1", + labels=["bug", "docs"], + repo_name="tasksmd-sync", + ), + ] + client = _mock_client(board) + tf = TaskFile( + tasks=[ + _make_task("Task", board_id="PVTI_1", labels=[]), + ] + ) + + result = execute_sync(client, tf) + + assert result.updated == 1 + client.resolve_label_ids.assert_not_called() + client.set_issue_labels.assert_called_once_with("I_1", []) + def test_update_error_recorded(self): """Errors during update should be captured in result.errors.""" board = [ @@ -785,11 +811,11 @@ def test_no_diff_when_task_has_no_assignee(self): board = _make_board_item("X", title="T", content_type="Issue", assignee="bob") assert _needs_update(task, board) is False - def test_no_diff_when_task_has_no_labels(self): - """If task has no labels, board's labels shouldn't cause a diff.""" + def test_labels_removed_when_task_has_no_labels(self): + """If task has no labels but board has labels, it SHOULD cause a diff.""" task = _make_task("T") board = _make_board_item("X", title="T", content_type="Issue", labels=["bug"]) - assert _needs_update(task, board) is False + assert _needs_update(task, board) is True def test_label_order_irrelevant(self): """Labels should be compared as sets (order doesn't matter).""" @@ -953,6 +979,25 @@ def test_issue_without_content_id_skips_assignee_labels(self): client.resolve_user_id.assert_not_called() client.set_issue_labels.assert_not_called() + def test_issue_clears_labels_when_task_has_none(self): + """Real Issue should have labels cleared when task has no labels.""" + client = _mock_client() + fields = _stub_fields() + task = _make_task("T", labels=[]) + bi = _make_board_item( + "PVTI_1", + title="T", + content_type="Issue", + content_id="I_1", + labels=["bug", "docs"], + repo_name="tasksmd-sync", + ) + + _apply_task_fields(client, "PVTI_1", task, fields, board_item=bi) + + client.resolve_label_ids.assert_not_called() + client.set_issue_labels.assert_called_once_with("I_1", []) + # =================================================================== # _parse_item_node diff --git a/tests/test_sync.py b/tests/test_sync.py index 7f7b3df..8f56d2f 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -403,3 +403,41 @@ def test_issue_unchanged_when_labels_match(): plan = build_sync_plan(tf, board) assert len(plan.unchanged) == 1 assert len(plan.update) == 0 + + +def test_label_removal_triggers_update_for_issue(): + """Removing all labels from a task should trigger an update on a real Issue.""" + tf = TaskFile( + tasks=[ + _make_task("Task", board_id="PVTI_1", labels=[]), + ] + ) + board = [ + _make_board_item( + "PVTI_1", + title="Task", + content_type="Issue", + labels=["bug", "docs"], + ), + ] + plan = build_sync_plan(tf, board) + assert len(plan.update) == 1 + + +def test_partial_label_removal_triggers_update_for_issue(): + """Removing some labels from a task should trigger an update on a real Issue.""" + tf = TaskFile( + tasks=[ + _make_task("Task", board_id="PVTI_1", labels=["bug"]), + ] + ) + board = [ + _make_board_item( + "PVTI_1", + title="Task", + content_type="Issue", + labels=["bug", "docs"], + ), + ] + plan = build_sync_plan(tf, board) + assert len(plan.update) == 1