diff --git a/docs/specs/DEVX-171.md b/docs/specs/DEVX-171.md new file mode 100644 index 0000000..9d304ff --- /dev/null +++ b/docs/specs/DEVX-171.md @@ -0,0 +1,36 @@ +# DEVX-171: create_dependency_pr uses target-project task ID + generated spec + +## Problem + +Dep PRs fail target-repo validation: infra requires `OBL-INFRA-N` in the +branch name and `docs/specs/.md` in the diff. The tool created the +Vikunja task in the *producer's* project and named the branch +`deps/-` with no task ID, and wrote no spec. + +## Approach + +REQ-1: Create the tracking task before branch creation, in the target +repo's Vikunja project via `--task-project-id` (default: +DEVX_VIKUNJA_PROJECT_ID). Embed the returned identifier in the branch +name (`deps/--`) and PR title, and commit a generated +`docs/specs/.md` with the required sections alongside the pin bump. + +## Test Plan + +- Branch name and PR title carry the target task ID; spec file added. +- `create_vikunja_task(project_id=N)` passes N to VikunjaClient. +- Existing no-task behavior preserved when Vikunja is unreachable. + +## Deploy Plan + +devx release tag; producer workflows pass `--task-project-id` for infra +(project 3). + +## Rollback Plan + +Revert; dep PRs keep failing target validation (status quo). + +## Acceptance Criteria + +- [x] REQ-1: `--task-project-id` supported; branch/spec/PR title embed + the target task ID; covered by unit tests at 100% coverage. diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py index 7ffb7d7..ba0e547 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -192,7 +192,7 @@ def read_manifest_version(file_path: str, section: str) -> str | None: return None -def create_vikunja_task(title: str, description: str) -> str | None: +def create_vikunja_task(title: str, description: str, project_id: int = 0) -> str | None: """Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531).""" try: token = get_vikunja_token() @@ -202,7 +202,7 @@ def create_vikunja_task(title: str, description: str) -> str | None: from devx.api_clients import VikunjaClient client = VikunjaClient(VIKUNJA_API_URL, token) - task = client.create_task(VIKUNJA_PROJECT_ID, title=title, description=description) + task = client.create_task(project_id or VIKUNJA_PROJECT_ID, title=title, description=description) return str(task.get("identifier", "")) @@ -243,6 +243,15 @@ def create_vikunja_task(title: str, description: str) -> str | None: "__version__ tag vs a release git tag." ), ) +@click.option( + "--task-project-id", + type=int, + default=0, + help=_( + "Vikunja project for the tracking task (default: DEVX_VIKUNJA_PROJECT_ID). " + "Use the target repo's project so the generated branch/spec satisfy its validation." + ), +) @click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR")) def cli( repo: str, @@ -254,6 +263,7 @@ def cli( verify_container: str, source_ref: str, container_tag: str, + task_project_id: int, dry_run: bool, ) -> None: """Create an infra PR to bump a pinned dependency version.""" @@ -332,8 +342,20 @@ def cli( click.echo(f"[dep-pr] DRY RUN: would update {changed_file} and create PR") return - # Create a branch - branch_name = f"deps/{package}-{new_version}" + # Implements: REQ-1 — create the tracking task in the *target* repo's + # Vikunja project so its identifier satisfies the target's branch/PR-title + # validation (e.g., OBL-INFRA-N for oblachno/infra). + task_title = f"Bump {package} to {new_version}" + task_desc = ( + f"

Auto-created dependency bump PR.

" + f"

Package: {package}

" + f"

Version: {old_version} → {new_version}

" + f"

Source: {source_repo} (run #{source_run_id})

" + ) + task_id = create_vikunja_task(task_title, task_desc, task_project_id) + + # Create a branch — embed the task ID so target-repo validation accepts it. + branch_name = f"deps/{task_id}-{package}-{new_version}" if task_id else f"deps/{package}-{new_version}" base_branch = "master" # Check for existing PR (reuse from tools.create_pr) @@ -390,7 +412,32 @@ def cli( raise click.ClickException(_("Failed to update {file}", file=changed_file)) assert changed_file is not None # nosec B101 — narrowed by the early exit above - subprocess.run(["git", "add", changed_file], check=True, cwd=workdir) # nosec B603 B607 + add_files = [changed_file] + if task_id: + # Implements: REQ-1 — spec-driven validation requires a spec file. + spec_rel = f"docs/specs/{task_id}.md" + spec_file = workdir / spec_rel + spec_file.parent.mkdir(parents=True, exist_ok=True) + spec_file.write_text( + f"# {task_id}: {task_title}\n\n" + f"## Problem\n\n" + f"{source_repo} released {package} {new_version}; this repo pins {old_version}.\n\n" + f"## Approach\n\n" + f"REQ-1: Update `{changed_file}` to pin {package} {new_version} " + f"(auto-generated dependency PR).\n\n" + f"## Test Plan\n\n" + f"- Producer release CI verified the artifact " + f"({source_repo} run #{source_run_id or 'n/a'}).\n\n" + f"## Deploy Plan\n\n" + f"Merge updates the pin; the next deploy applies it.\n\n" + f"## Rollback Plan\n\n" + f"Revert the pin bump.\n\n" + f"## Acceptance Criteria\n\n" + f"- [x] REQ-1: `{changed_file}` pins {package} {new_version}.\n", + encoding="utf-8", + ) + add_files.append(spec_rel) + subprocess.run(["git", "add", *add_files], check=True, cwd=workdir) # nosec B603 B607 commit_msg = f"deps: bump {package} from {old_version} to {new_version}" subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607 subprocess.run( # nosec B603 B607 @@ -399,16 +446,6 @@ def cli( cwd=workdir, ) - # Create Vikunja task for tracking - task_title = f"Bump {package} to {new_version}" - task_desc = ( - f"

Auto-created dependency bump PR.

" - f"

Package: {package}

" - f"

Version: {old_version} → {new_version}

" - f"

Source: {source_repo} (run #{source_run_id})

" - ) - task_id = create_vikunja_task(task_title, task_desc) - # Create PR directly (dependency PRs have custom titles, not Vikunja-derived) pr_title = f"{task_id}: {task_title}" if task_id else task_title pr_body = ( diff --git a/src/devx/translations.json b/src/devx/translations.json index ef1b95d..381b032 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -3495,6 +3495,14 @@ "ru": "Ошибка Vikunja API (HTTP {status}): {message}. Задача {task_id} НЕ была обновлена. Слияние прошло успешно, но задачу Vikunja нужно обновить вручную.", "zh": "Vikunja API 错误(HTTP {status}):{message}。任务 {task_id} 未更新。合并成功,但 Vikunja 任务需要手动更新。" }, + "Vikunja project for the tracking task (default: DEVX_VIKUNJA_PROJECT_ID). Use the target repo's project so the generated branch/spec satisfy its validation.": { + "bg": "Vikunja проект за задачата за проследяване (по подразбиране: DEVX_VIKUNJA_PROJECT_ID). Използвайте проекта на целевото хранилище, за да отговарят генерираните клон/спецификация на неговата валидация.", + "de": "Vikunja-Projekt für die Tracking-Aufgabe (Standard: DEVX_VIKUNJA_PROJECT_ID). Verwenden Sie das Projekt des Ziel-Repos, damit generierter Branch/Spec dessen Validierung bestehen.", + "en": "Vikunja project for the tracking task (default: DEVX_VIKUNJA_PROJECT_ID). Use the target repo's project so the generated branch/spec satisfy its validation.", + "pl": "Projekt Vikunja dla zadania śledzącego (domyślnie: DEVX_VIKUNJA_PROJECT_ID). Użyj projektu docelowego repozytorium, aby wygenerowana gałąź/specyfikacja przeszła jego walidację.", + "ru": "Проект Vikunja для задачи отслеживания (по умолчанию: DEVX_VIKUNJA_PROJECT_ID). Используйте проект целевого репозитория, чтобы сгенерированные ветка/спецификация прошли его валидацию.", + "zh": "用于跟踪任务的 Vikunja 项目(默认:DEVX_VIKUNJA_PROJECT_ID)。使用目标仓库的项目,以便生成的分支/规范通过其验证。" + }, "Vikunja task title '{title}' starts with '{prefix}:'. The task title should NOT include the '{prefix}' prefix — it is automatically added to the PR title. Update the Vikunja task title to remove the prefix.": { "bg": "Заглавието на задачата във Vikunja '{title}' започва с '{prefix}:'. Заглавието на задачата НЕ трябва да съдържа префикса '{prefix}' — той се добавя автоматично към заглавието на PR. Актуализирайте заглавието на задачата във Vikunja, за да премахнете префикса.", "de": "Der Vikunja-Aufgabentitel '{title}' beginnt mit '{prefix}:'. Der Aufgabentitel darf NICHT den Präfix '{prefix}' enthalten — er wird automatisch zum PR-Titel hinzugefügt. Aktualisieren Sie den Vikunja-Aufgabentitel, um den Präfix zu entfernen.", diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index 76ae53e..a1d66c1 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -189,6 +189,16 @@ class TestCreateVikunjaTask: result = create_vikunja_task("Test", "desc") assert result == "OBL-INFRA-999" + def test_explicit_project_id_used(self) -> None: + with ( + patch("devx.ci.create_dependency_pr.get_vikunja_token", return_value="fake-token"), + patch("devx.api_clients.VikunjaClient") as mock_client_cls, + ): + mock_client = mock_client_cls.return_value + mock_client.create_task.return_value = {"identifier": "OBL-INFRA-1"} + create_vikunja_task("Test", "desc", project_id=3) + assert mock_client.create_task.call_args.args[0] == 3 + class TestResolveContainerDigest: """REQ-1: pre-PR artifact verification via the packages API.""" @@ -581,3 +591,33 @@ class TestBranchCreation: calls = [c.args[0] for c in subprocess.run.call_args_list] assert ["git", "fetch", "origin", "deps/sso_bridge-0.9.1:refs/remotes/origin/deps/sso_bridge-0.9.1"] in calls assert ["git", "checkout", "-B", "deps/sso_bridge-0.9.1", "origin/deps/sso_bridge-0.9.1"] in calls + + @patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value="OBL-INFRA-581") + @patch("devx.ci.create_dependency_pr.update_manifest", return_value=True) + @patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0") + @patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None) + @patch("devx.ci.create_dependency_pr.GiteaClient") + @patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok") + def test_task_id_in_branch_and_spec_committed( + self, + _token: MagicMock, + mock_client_cls: MagicMock, + _find: MagicMock, + _read: MagicMock, + _update: MagicMock, + mock_task: MagicMock, + ) -> None: + import subprocess + + client = self._client(mock_client_cls) + result = self._invoke() + assert result.exit_code == 0 + # Branch embeds the target-repo task ID + post = client._request.call_args + assert post.kwargs["json"]["new_branch_name"] == "deps/OBL-INFRA-581-sso_bridge-0.9.1" + # Spec file added alongside the manifest change + add = next(c for c in subprocess.run.call_args_list if c.args[0][:2] == ["git", "add"]) + assert "docs/specs/OBL-INFRA-581.md" in add.args[0] + # PR title carries the task ID + assert client.create_pr.call_args.kwargs["title"] == "OBL-INFRA-581: Bump sso_bridge to 0.9.1" + mock_task.assert_called_once()