diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9133910..f117061 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -120,8 +120,8 @@ jobs: auto-merge: # Auto-merge runs after all CI checks pass. It reads the task ID - # from .taskid file, validates the PR title, and squash-merges. - # No manual label or review needed — CI is the quality gate. + # from the branch name (falling back to .taskid file), validates + # the PR title, and squash-merges. needs: [quality, detect-changes, pr-review] if: github.event_name == 'pull_request' runs-on: docker diff --git a/AGENTS.md b/AGENTS.md index d0acb5f..7ff6922 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -285,6 +285,37 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`. | PR title | `DEVX-N: ` | `DEVX-12: Add release automation` | | Merge commit | `DEVX-N ` | `DEVX-12 feat: add release script` | +### 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 + +### Workflow `auto-merge` Job and `always()` + +When `auto-merge` depends on a job that can be skipped (e.g. +`molecule-tests`), the `if:` condition MUST include `always() &&` +at the start. Without it, Gitea Actions skips `auto-merge` when any +dependency is skipped, even if the condition explicitly allows +`result == 'skipped'`. + +```yaml +auto-merge: + needs: [quality, detect-changes, pr-review, molecule-tests] + if: >- + always() && + github.event_name == 'pull_request' && + needs.quality.result == 'success' && + needs.pr-review.result == 'success' && + (needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped') +``` + ## Config System devx uses environment variables with `.env` file fallback for configuration. @@ -295,6 +326,10 @@ devx uses environment variables with `.env` file fallback for configuration. |----------|---------|-------------| | `DEVX_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API base URL | | `DEVX_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API base URL | +| `DEVX_REPO_OWNER` | **(none — must be set)** | Repository owner for API calls | +| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) | +| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) | +| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID | | `DEVX_LANG` | `en` | Language for i18n (en, bg) | | `REPO_TOKEN` | (from .env) | Gitea API token | | `VIKUNJA_TOKEN` | (from .env) | Vikunja API token | diff --git a/src/devx/ci/auto_merge.py b/src/devx/ci/auto_merge.py index 6e505f2..d8d9acb 100644 --- a/src/devx/ci/auto_merge.py +++ b/src/devx/ci/auto_merge.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Auto-merge PR when all CI checks pass. -Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file -(falling back to branch name extraction for backwards compatibility), +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. @@ -68,15 +68,38 @@ def read_taskid(branch: str) -> str: 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. + + If both sources exist and disagree, a warning is printed and the + branch task ID is preferred (it is the current source of truth). """ branch_task_id = extract_task_id(branch) if branch_task_id: + # Check for stale .taskid file that disagrees with branch name + 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.", + 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 "" diff --git a/src/devx/config.py b/src/devx/config.py index 0aa86b4..b84e8a0 100644 --- a/src/devx/config.py +++ b/src/devx/config.py @@ -13,8 +13,9 @@ import re GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1") -# Organization defaults -REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "oblachno-oss") +# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly. +# No default: prevents silent 404s when the wrong owner is used. +REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "") # Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.) TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX") diff --git a/src/devx/tools/configure_repo.py b/src/devx/tools/configure_repo.py index f706153..cf40cb7 100644 --- a/src/devx/tools/configure_repo.py +++ b/src/devx/tools/configure_repo.py @@ -155,6 +155,19 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) if not repo: raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.")) + # If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo. + # This prevents 404s when workflows set DEVX_REPO_NAME to the full path. + if "/" in repo and owner is None: + parts = repo.split("/", 1) + owner, repo = parts[0], parts[1] + click.echo( + _( + "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME", + owner=owner, + repo=repo, + ) + ) + if owner is None: owner = REPO_OWNER diff --git a/src/devx/translations.json b/src/devx/translations.json index 0081307..21bc9d5 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -811,6 +811,13 @@ "ru": "Ой! Публикация в PyPI не удалась:\n{stderr}", "zh": "哎呀!PyPI 发布失败:\n{stderr}" }, + "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": { + "bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME", + "de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert", + "en": "Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME", + "ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME", + "zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}" + }, "PASSED: {pair}": { "bg": "PASSED: {pair}", "de": "PASSED: {pair}", @@ -986,6 +993,13 @@ "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.", @@ -1070,6 +1084,13 @@ "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.", diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 726aca4..d5483ce 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -46,6 +46,16 @@ class TestReadTaskid: (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 + 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 + # -- extract_task_id (legacy fallback) -- diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5803a17..1070b12 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -37,7 +37,7 @@ class TestConfigConstants: assert DEFAULT_PER_PAGE == 50 def test_owner(self) -> None: - assert REPO_OWNER == "oblachno-oss" + assert REPO_OWNER == "" def test_task_prefix(self) -> None: assert TASK_PREFIX == "DEVX" diff --git a/tests/unit/test_configure_repo.py b/tests/unit/test_configure_repo.py index 7d9a790..ebd3863 100644 --- a/tests/unit/test_configure_repo.py +++ b/tests/unit/test_configure_repo.py @@ -163,3 +163,37 @@ class TestMain: mock_client.ensure_branch_protection.assert_called_once() args = mock_client.ensure_branch_protection.call_args assert args[0][0] == "develop" + + @patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True) + @patch("devx.tools.configure_repo.GiteaClient") + def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None: + """DEVX_REPO_NAME with 'owner/repo' format should be split.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + # Verify GiteaClient was constructed with parsed owner and repo (positional) + call_args = mock_client_cls.call_args + assert call_args[0][2] == "oblachno" # owner is 3rd positional arg + assert call_args[0][3] == "infra" # repo is 4th positional arg + + @patch.dict( + "os.environ", + {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"}, + clear=True, + ) + @patch("devx.tools.configure_repo.REPO_OWNER", "oblachno") + @patch("devx.tools.configure_repo.GiteaClient") + def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None: + """When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + call_args = mock_client_cls.call_args + assert call_args[0][2] == "oblachno" # owner + assert call_args[0][3] == "infra" # repo