diff --git a/.gitignore b/.gitignore index 7dcbcc9..23f8d53 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ Thumbs.db # Badges .badges/ + +# Deprecated CI task tracking (branch name is the sole source of truth) +.taskid diff --git a/.taskid b/.taskid deleted file mode 100644 index 9d89668..0000000 --- a/.taskid +++ /dev/null @@ -1 +0,0 @@ -DEVX-30 diff --git a/AGENTS.md b/AGENTS.md index 7ff6922..d3851b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,15 +287,10 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`. ### Task ID Resolution -`auto_merge` resolves the task ID from the branch name first (e.g. -`DEVX-12-fix-foo` → `DEVX-12`), falling back to the `.taskid` file -for branches without a task ID prefix. If both exist and disagree, -a warning is printed and the branch task ID is preferred. - -**When creating a new branch from an existing branch**, the `.taskid` -file may be stale (it contains the old branch's task ID). Either: -1. Update `.taskid` to match the new branch's task ID, or -2. Delete `.taskid` — the branch name is the primary source of truth +`auto_merge` resolves the task ID solely from the branch name (e.g. +`DEVX-12-fix-foo` → `DEVX-12`). Branch names must include the task ID +prefix — there is no `.taskid` file fallback. If a stale `.taskid` file +exists in the repo, a deprecation warning is printed advising its removal. ### Workflow `auto-merge` Job and `always()` diff --git a/src/devx/ci/auto_merge.py b/src/devx/ci/auto_merge.py index d8d9acb..c632b60 100644 --- a/src/devx/ci/auto_merge.py +++ b/src/devx/ci/auto_merge.py @@ -2,9 +2,9 @@ """Auto-merge PR when all CI checks pass. Runs as the final job in ci.yml. Reads the task ID from the branch name -(falling back to ``.taskid`` file for branches without a task ID prefix), -validates the PR title, and squash-merges with a conventional commit -message prefixed by the task ID. +(e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``), validates the PR title against +the Vikunja task, and squash-merges with a conventional commit message +prefixed by the task ID. PR title format: ``{PREFIX}-N: `` Merge commit format: ``{PREFIX}-N `` @@ -42,7 +42,7 @@ from devx.config import ( from devx.exceptions import APIError from devx.i18n import _ -TASKID_FILE = ".taskid" +TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") load_dotenv() @@ -63,44 +63,31 @@ def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[ def read_taskid(branch: str) -> str: - """Read task ID from branch name, falling back to .taskid file. + """Read task ID from branch name. - The branch name is the primary source of truth for the task ID - (e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). The ``.taskid`` file - is a legacy fallback for branches without a task ID prefix. + The branch name is the sole source of truth for the task ID + (e.g., ``DEVX-31-fix-foo`` → ``DEVX-31``). Branches must include + the task ID prefix — there is no ``.taskid`` file fallback. - If both sources exist and disagree, a warning is printed and the - branch task ID is preferred (it is the current source of truth). + If a stale ``.taskid`` file exists and disagrees with the branch + name, a deprecation warning is printed advising its removal. """ branch_task_id = extract_task_id(branch) if branch_task_id: - # Check for stale .taskid file that disagrees with branch name + # Warn about stale .taskid file if it exists and disagrees path = Path(TASKID_FILE) if path.exists(): file_task_id = path.read_text(encoding="utf-8").strip() if file_task_id and file_task_id != branch_task_id: click.echo( _( - "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). " - "Using branch task ID. Update or delete .taskid to silence this warning.", + "WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). " + "Delete .taskid from the repo — branch name is the sole source of truth.", file_id=file_task_id, branch_id=branch_task_id, ) ) return branch_task_id - # Fallback: read from .taskid file - path = Path(TASKID_FILE) - if path.exists(): - task_id = path.read_text(encoding="utf-8").strip() - if task_id: - click.echo( - _( - "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')", - task_id=task_id, - branch=branch, - ) - ) - return task_id return "" @@ -229,7 +216,8 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: if not task_id: raise click.ClickException( _( - "Oops! No task ID found in .taskid file or branch name '{branch}'.", + "Oops! No task ID found in branch name '{branch}'. " + "Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).", branch=branch, ) ) diff --git a/src/devx/ci/classify_changes.py b/src/devx/ci/classify_changes.py index 1581dbc..18d61b9 100644 --- a/src/devx/ci/classify_changes.py +++ b/src/devx/ci/classify_changes.py @@ -298,8 +298,6 @@ DEFAULT_INFRASTRUCTURE: list[str] = [ "activate.sh", "activate.fish", "activate.zsh", - # CI task tracking file (written by CI, not by developers) - ".taskid", ] diff --git a/src/devx/translations.json b/src/devx/translations.json index 21bc9d5..0d0b913 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -776,13 +776,6 @@ "ru": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}", "zh": "Oops! Master branch commits must start with a task ID.\n Expected: {prefix}-N: \n Got: {subject}" }, - "Oops! No task ID found in .taskid file or branch name '{branch}'.": { - "bg": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "de": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "en": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "ru": "Oops! No task ID found in .taskid file or branch name '{branch}'.", - "zh": "Oops! No task ID found in .taskid file or branch name '{branch}'." - }, "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}": { "bg": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", "de": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}", @@ -993,13 +986,6 @@ "ru": "Task ID: {task_id}", "zh": "Task ID: {task_id}" }, - "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')": { - "bg": "Task ID от .taskid файл: {task_id} (не е намерен в името на клона '{branch}')", - "de": "Task ID aus .taskid-Datei: {task_id} (nicht im Branch-Namen '{branch}' gefunden)", - "en": "Task ID from .taskid file: {task_id} (not found in branch name '{branch}')", - "ru": "Task ID из файла .taskid: {task_id} (не найден в имени ветки '{branch}')", - "zh": "来自 .taskid 文件的 Task ID: {task_id}(在分支名 '{branch}' 中未找到)" - }, "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": { "bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.", "de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.", @@ -1084,13 +1070,6 @@ "ru": "WARNING: --skip-tests passed — skipping test verification.", "zh": "WARNING: --skip-tests passed — skipping test verification." }, - "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.": { - "bg": "ВНИМАНИЕ: .taskid файл ({file_id}) не съвпада с името на клона ({branch_id}). Използва се task ID от клона. Актуализирайте или изтрийте .taskid за да премахнете това предупреждение.", - "de": "WARNUNG: .taskid-Datei ({file_id}) stimmt nicht mit Branch-Namen ({branch_id}) überein. Branch-Task-ID wird verwendet. Aktualisieren oder löschen Sie .taskid, um diese Warnung zu unterdrücken.", - "en": "WARNING: .taskid file ({file_id}) disagrees with branch name ({branch_id}). Using branch task ID. Update or delete .taskid to silence this warning.", - "ru": "ВНИМАНИЕ: файл .taskid ({file_id}) не совпадает с именем ветки ({branch_id}). Используется Task ID из ветки. Обновите или удалите .taskid, чтобы скрыть это предупреждение.", - "zh": "警告:.taskid 文件 ({file_id}) 与分支名 ({branch_id}) 不一致。使用分支 Task ID。更新或删除 .taskid 以消除此警告。" - }, "Warning: could not fetch tags from origin.": { "bg": "Warning: could not fetch tags from origin.", "de": "Warning: could not fetch tags from origin.", @@ -1258,5 +1237,19 @@ "en": "{file} already exists. Use --force to overwrite.", "ru": "{file} already exists. Use --force to overwrite.", "zh": "{file} already exists. Use --force to overwrite." + }, + "Oops! No task ID found in branch name '{branch}'. Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).": { + "en": "Oops! No task ID found in branch name '{branch}'. Branch names must include the task ID prefix (e.g., DEVX-31-fix-bug).", + "bg": "Ой! Не е намерен ID на задача в името на клона '{branch}'. Имената на клонове трябва да включват префикса за ID на задача (напр. DEVX-31-fix-bug).", + "de": "Hoppla! Keine Task-ID im Branch-Namen '{branch}' gefunden. Branch-Namen müssen das Task-ID-Präfix enthalten (z.B. DEVX-31-fix-bug).", + "ru": "Ой! ID задачи не найден в имени ветки '{branch}'. Имена веток должны включать префикс ID задачи (например, DEVX-31-fix-bug).", + "zh": "哎呀!在分支名称 '{branch}' 中未找到任务 ID。分支名称必须包含任务 ID 前缀(例如 DEVX-31-fix-bug)。" + }, + "WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). Delete .taskid from the repo — branch name is the sole source of truth.": { + "en": "WARNING: .taskid file ({file_id}) is deprecated and disagrees with branch name ({branch_id}). Delete .taskid from the repo — branch name is the sole source of truth.", + "bg": "ВНИМАНИЕ: Файлът .taskid ({file_id}) е остарял и не съвпада с името на клона ({branch_id}). Изтрийте .taskid от хранилището — името на клона е единственият източник на истината.", + "de": "WARNUNG: Die Datei .taskid ({file_id}) ist veraltet und stimmt nicht mit dem Branch-Namen ({branch_id}) überein. Löschen Sie .taskid aus dem Repo — der Branch-Name ist die einzige Wahrheitsquelle.", + "ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.", + "zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。" } } diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index d5483ce..17f1862 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -21,40 +21,31 @@ from devx.exceptions import APIError class TestReadTaskid: - def test_prefers_branch_name_over_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-60\n") - # Branch name takes priority over .taskid file - assert read_taskid("DEVX-19-fix-bug") == "DEVX-19" - - def test_falls_back_to_file_when_no_branch_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-60\n") - # No task ID in branch name → fall back to .taskid - assert read_taskid("some-branch") == "DEVX-60" - - def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] + def test_extracts_from_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) assert read_taskid("DEVX-19-fix-bug") == "DEVX-19" - def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] + def test_returns_empty_when_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) assert read_taskid("feature-branch") == "" - def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("\n") - assert read_taskid("DEVX-42-test") == "DEVX-42" - def test_warns_on_stale_taskid_file(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) (tmp_path / ".taskid").write_text("DEVX-60\n") - # Branch name takes priority, but stale .taskid should produce a warning + # Branch name takes priority, stale .taskid should produce deprecation warning assert read_taskid("DEVX-19-fix-bug") == "DEVX-19" captured = capsys.readouterr() - assert "WARNING" in captured.out - assert "DEVX-60" in captured.out - assert "DEVX-19" in captured.out + combined = captured.out + captured.err + assert "WARNING" in combined + assert "deprecated" in combined + assert "DEVX-60" in combined + assert "DEVX-19" in combined + + def test_no_warning_when_taskid_file_absent(self, tmp_path, monkeypatch, capsys) -> None: # type: ignore[no-untyped-def] + monkeypatch.chdir(tmp_path) + assert read_taskid("DEVX-42-test") == "DEVX-42" + captured = capsys.readouterr() + assert "WARNING" not in captured.out # -- extract_task_id (legacy fallback) -- @@ -217,7 +208,6 @@ class TestMain: self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch ) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") mock_client = MagicMock() mock_client.get_pr_commits.return_value = [ @@ -244,7 +234,7 @@ class TestMain: @patch("devx.ci.auto_merge.GiteaClient") def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) - # No .taskid file, no DEVX-N in branch name + # No DEVX-N in branch name runner = CliRunner() result = runner.invoke(main, ["feature-branch", "DEVX-19: test", "owner/repo", "7"]) assert result.exit_code != 0 @@ -254,7 +244,6 @@ class TestMain: @patch("devx.ci.auto_merge.GiteaClient") def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") runner = CliRunner() result = runner.invoke(main, ["DEVX-19-fix", "Bad title", "owner/repo", "7"]) @@ -268,7 +257,6 @@ class TestMain: self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch ) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") mock_client = MagicMock() mock_client.get_pr_commits.return_value = [ @@ -298,7 +286,6 @@ class TestMain: self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch ) -> None: # type: ignore[no-untyped-def] monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") mock_client = MagicMock() mock_client.get_pr_commits.return_value = [ @@ -323,7 +310,6 @@ class TestMain: ) -> None: # type: ignore[no-untyped-def] """When no conventional commit message is found in PR commits, raises.""" monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") mock_client = MagicMock() mock_client.get_pr_commits.return_value = [] @@ -341,7 +327,6 @@ class TestMain: def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None: """Non-integer PR number should raise.""" monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") runner = CliRunner() result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "owner/repo", "not-a-number"]) assert result.exit_code != 0 @@ -351,7 +336,6 @@ class TestMain: def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None: """Repo without owner/name should raise.""" monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") runner = CliRunner() result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: Test", "invalidrepo", "7"]) assert result.exit_code != 0 @@ -365,7 +349,6 @@ class TestMain: ) -> None: # type: ignore[no-untyped-def] """When rebase retry also fails, raises with helpful message.""" monkeypatch.chdir(tmp_path) - (tmp_path / ".taskid").write_text("DEVX-19\n") mock_client = MagicMock() mock_client.get_pr_commits.return_value = [