Compare commits

...
7 Commits
Author SHA1 Message Date
Emil Simeonov 2bdb7542fc fix(ci): run dependency PR file/git ops inside a clone of the target repo
CI / validate (pull_request) Failing after 1m10s
CI / auto-merge (pull_request) Skipped
2026-09-19 04:22:11 +02:00
Emil Simeonov 0fc1d82365 feat(ci): add --container-tag to decouple image tag from release version
CI / validate (pull_request) Failing after 47s
CI / auto-merge (pull_request) Skipped
2026-09-19 04:18:36 +02:00
Emil Simeonov 8cbf148745 feat(ci): manifest-aware dependency PRs and cleanup protection
CI / validate (pull_request) Failing after 49s
CI / auto-merge (pull_request) Skipped
Implements: DEVX-165 REQ-1..3

- create_dependency_pr gains --manifest/--verify-container/--source-ref:
  resolves the producer image's OCI digest via the packages API before
  opening the PR, then writes structured manifest fields
  (version/git_ref/image_tag/image_digest/updated_at) in the PR branch
- clean_images gains --protect: pinned versions survive --keep trimming
  so a manifest-referenced digest can't be garbage-collected
- regression tests for digest resolution, manifest helpers, verify
  ordering, and protect filtering
2026-09-19 04:13:11 +02:00
kireto 9b90816be4 DEVX-164: fix(ci): retry Vikunja lookups and surface self-approval merge failures
Post-merge / detect-and-configure (push) Successful in 1m12s
Post-merge / release-and-maintain (push) Failing after 1m14s
2026-09-19 02:01:53 +00:00
gitea-actions-bot edb9205ce6 chore: update badge URLs to commit ef6e8cf7 [skip ci] 2026-09-18 22:48:23 +00:00
kireto b2ac1cd06c DEVX-163: docs: add vikunja-tasks skill and skill validation tests, fix create-task docs
Post-merge / detect-and-configure (push) Successful in 9s
Post-merge / release-and-maintain (push) Successful in 41s
2026-09-18 22:47:32 +00:00
gitea-actions-bot f90360faef chore: update badge URLs to commit f3f3fa7d [skip ci] 2026-09-17 09:45:17 +00:00
20 changed files with 1277 additions and 96 deletions
+11 -1
View File
@@ -2,11 +2,21 @@
Quick reference for devx tools when working on the devx repo itself.
## When to Invoke
Invoke this skill when creating PRs, checking CI status, adding
labels, rebasing branches, or performing any PR lifecycle operation.
## Prerequisites
- `.venv` exists (run `make setup` if not)
- `.env` with `DEVELOPER_GITEA_API_TOKEN`, `VIKUNJA_TOKEN`
## PR Workflow (use these, not raw git/tea/MCP)
| Task | Command |
|------|---------|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
| Create Vikunja task | `.venv/bin/python -m devx.tools.create_task --title "..." --description "..."` (make target doesn't forward args) |
| Create PR | `make create-pr` |
| Push + create PR | `make push-with-pr` |
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
@@ -1,5 +1,14 @@
# Spec-Driven Development
## When to Invoke
Invoke this skill when starting any change — every PR requires a spec
at `docs/specs/<TASK-ID>.md` that CI validates before merge.
## Prerequisites
- A Vikunja task ID (`DEVX-N`) — see `vikunja-tasks` skill
## Overview
Every change starts with a spec. No spec, no code. No code, no PR.
@@ -3,6 +3,17 @@
Make targets for testing, debugging, and CI investigation. **Use these
instead of raw `pytest`, `ruff`, or `actionlint` commands.**
## When to Invoke
Invoke this skill when running tests, investigating CI failures, or
linting before push. Also invoke when asked to "run tests", "check
coverage", or "debug a failure".
## Prerequisites
- `.venv` exists (run `make setup` if not)
- Tools installed (run `make install-tools` for actionlint/act_runner)
## Why Make Targets
Make targets encapsulate the correct venv activation, PYTHONPATH, env
+74
View File
@@ -0,0 +1,74 @@
# vikunja-tasks
Vikunja task lifecycle beyond `create`: querying status, closing, and
recovering when the tracker is unreachable.
## When to Invoke
- Creating, closing, or checking a Vikunja task
- A spec workflow step needs the task ID or done state
- `vikunja.oblachno.oblachno.fyi` fails to resolve / times out
## Prerequisites
- `.env` with `VIKUNJA_TOKEN`
- Project ID comes from `[tool.devx]` in `pyproject.toml`
(`DEVX_VIKUNJA_PROJECT_ID`)
## Create
`make create-task` does **not** forward arguments — call the module:
```bash
.venv/bin/python -m devx.tools.create_task \
--title "Task title (no DEVX-N prefix)" \
--description "<h2>Context</h2><p>...</p>"
```
Prints `DEVX-N` + next steps. Title must not include the task-ID
prefix (auto-merge prepends it; a manual prefix double-prefixes the
PR title and fails validation).
## Query / Close
```bash
# Task details (ID = numeric part of DEVX-N)
curl -sf -H "Authorization: Bearer $VIKUNJA_TOKEN" \
"https://vikunja.oblachno.oblachno.fyi/api/v1/tasks/<N>"
# Close: mark done
curl -sf -X POST -H "Authorization: Bearer $VIKUNJA_TOKEN" \
-H "Content-Type: application/json" -d '{"done":true}' \
"https://vikunja.oblachno.oblachno.fyi/api/v1/tasks/<N>"
```
Post-merge automation marks the task done when the PR squash-merges —
manual close is only needed for abandoned/superseded tasks.
## Task-ID / Spec Collisions
Vikunja IDs can collide with historical spec files (an old task reused
the number). Convention: preserve the old file as
`docs/specs/<ID>-<topic>-historical.md`, then write the new spec at
`docs/specs/<ID>.md`. Check `git log` on the existing spec before
moving it.
## Tracker Unreachable
If the Vikunja host fails DNS/TLS:
1. Don't block the whole workflow — record the intended task title in
the spec draft and retry `create_task` before branching.
2. Never invent an ID — branch/PR titles must match a real task or
`pre_push_check` / auto-merge validation fails.
3. DNS failures observed so far were transient; retry after a few
minutes before escalating.
## Common Mistakes
- `make create-task -- --title ...` — args are dropped; use the module
call above (forwarding fix is S11 scope).
- Including `DEVX-N:` in the task title — double prefix breaks
auto-merge.
- Closing a task whose PR is still open — auto-merge's post-merge
step handles the close; manual close confuses the audit trail.
+6 -6
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/python.svg)](https://www.python.org/downloads/)
## Why devx?
+6 -6
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/09be76620c0a26418c614fb26b7feec2a4f457d3/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -0,0 +1,33 @@
# DEVX-163: Fix _run_push to check stdout for HTTP 500
## Problem
`_run_push` only checked `result.stderr` for HTTP 500, but docker push
sends the "received unexpected HTTP status: 500 Internal Server Error"
message to **stdout**, not stderr. This means the tenacity retry logic
added in DEVX-162 never triggered — the push failed immediately without
retrying.
## Approach
Check both `result.stdout` and `result.stderr` for the "500" status code.
Also update the "already exists" check in `push_image` to check both
streams, since docker may send that message to stdout as well.
REQ-1: _run_push checks both stdout and stderr for HTTP 500
REQ-2: push_image "already exists" check uses combined stdout+stderr
REQ-3: All existing tests pass with 100% coverage
## Test Plan
- Unit tests for stdout 500 detection
- Unit tests for stderr 500 detection
- Manual: trigger build-images workflow and verify retry works
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: _run_push checks both stdout and stderr for HTTP 500
- [x] REQ-2: push_image "already exists" check uses combined stdout+stderr
- [x] REQ-3: All existing tests pass with 100% coverage
+33 -20
View File
@@ -1,33 +1,46 @@
# DEVX-163: Fix _run_push to check stdout for HTTP 500
# DEVX-163: Add vikunja-tasks skill and skill validation tests, fix create-task docs
## Problem
`_run_push` only checked `result.stderr` for HTTP 500, but docker push
sends the "received unexpected HTTP status: 500 Internal Server Error"
message to **stdout**, not stderr. This means the tenacity retry logic
added in DEVX-162 never triggered — the push failed immediately without
retrying.
The OBL-INFRA-548 programme audit found devx lacks a Vikunja
task-lifecycle skill and has no skill validation tests (infra and
sso-bridge have them; grm gained them under GRM-171).
`devx-workflow` documents `make create-task -- --title`, which fails
because `devx-create-task` forwards no arguments.
## Approach
Check both `result.stdout` and `result.stderr` for the "500" status code.
Also update the "already exists" check in `push_image` to check both
streams, since docker may send that message to stdout as well.
REQ-1: _run_push checks both stdout and stderr for HTTP 500
REQ-2: push_image "already exists" check uses combined stdout+stderr
REQ-3: All existing tests pass with 100% coverage
REQ-1: Add `vikunja-tasks` skill: create via module call, query,
close, spec-collision convention, unreachable-tracker handling.
REQ-2: Add `tests/unit/test_skills_validation.py` covering
structure, make-target, file-ref checks + existence tests for all
skills.
REQ-3: Fix broken `make create-task -- --title` documentation in
`devx-workflow` skill; add missing When to Invoke / Prerequisites
sections to older-format skills.
Preserve the colliding spec as
[DEVX-163-run-push-stdout-historical](DEVX-163-run-push-stdout-historical.md).
## Test Plan
- Unit tests for stdout 500 detection
- Unit tests for stderr 500 detection
- Manual: trigger build-images workflow and verify retry works
- `pytest tests/unit/test_skills_validation.py` passes (10 tests).
## Deploy Plan
- Merge to master
Documentation/skills only — auto-merge to master; no runtime deploy.
## Rollback Plan
- Revert the merge commit
Revert the squash-merge commit; skills are inert documentation.
## Acceptance Criteria
- [x] REQ-1: _run_push checks both stdout and stderr for HTTP 500
- [x] REQ-2: push_image "already exists" check uses combined stdout+stderr
- [x] REQ-3: All existing tests pass with 100% coverage
- [x] REQ-1: `vikunja-tasks` skill exists.
- [x] REQ-2: `tests/unit/test_skills_validation.py` exists and passes.
- [x] REQ-3: create-task docs corrected.
## Out of Scope
- Fixing `devx-create-task` argument forwarding (S11 backlog: the
devx.mak target takes no args; needs env-var or arg forwarding).
@@ -0,0 +1,34 @@
# DEVX-164: Increase HTTP 500 retry count and backoff for docker push
## Problem
The HTTP 500 retry logic (DEVX-162, DEVX-163) works correctly — 3 retry
attempts are made. But all 3 attempts fail because the Gitea registry's
"offset mismatch" race condition needs more than ~15s to recover. The
current backoff is 5s-20s with 3 attempts (total ~15s of waiting).
## Approach
Increase retry count from 3 to 5 and backoff from 5-20s to 10-60s,
giving the registry up to ~2 minutes to recover. Add visible logging
between retry attempts so the CI logs show the retry happening.
REQ-1: Increase retry count from 3 to 5
REQ-2: Increase backoff from 5-20s to 10-60s exponential
REQ-3: Add visible logging between retry attempts (click.echo)
REQ-4: All tests pass with 100% coverage
## Test Plan
- Unit tests verify retry count and backoff parameters
- Unit tests verify logging output on retry
- Manual: trigger build-images workflow and verify retries visible in logs
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Increase retry count from 3 to 5
- [x] REQ-2: Increase backoff from 5-20s to 10-60s exponential
- [x] REQ-3: Add visible logging between retry attempts (click.echo)
- [x] REQ-4: All tests pass with 100% coverage
+44 -21
View File
@@ -1,34 +1,57 @@
# DEVX-164: Increase HTTP 500 retry count and backoff for docker push
# DEVX-164: auto-merge resilience — self-approval and Vikunja outage handling
## Problem
The HTTP 500 retry logic (DEVX-162, DEVX-163) works correctly — 3 retry
attempts are made. But all 3 attempts fail because the Gitea registry's
"offset mismatch" race condition needs more than ~15s to recover. The
current backoff is 5s-20s with 3 attempts (total ~15s of waiting).
Two defects hit auto-merge during S02 work:
1. `get_vikunja_task_title` crashes on transient Vikunja errors. The
Vikunja API returned 404/502 during a restart window (run 6093);
`list_project_tasks` treats 4xx as non-retryable `APIError`, so the
job failed immediately instead of riding out a short outage.
2. When a PR author and the workflow's reviewer token map to the same
Gitea user, the auto-approve step is rejected ("approve your own
pull is not allowed") and the merge fails `405: not enough
approvals`. The generic merge error gives no hint that an external
approval is the fix (hit on sso-bridge #18, #19, and infra #1647's
approvals-only failure mode).
## Approach
Increase retry count from 3 to 5 and backoff from 5-20s to 10-60s,
giving the registry up to ~2 minutes to recover. Add visible logging
between retry attempts so the CI logs show the retry happening.
REQ-1: Increase retry count from 3 to 5
REQ-2: Increase backoff from 5-20s to 10-60s exponential
REQ-3: Add visible logging between retry attempts (click.echo)
REQ-4: All tests pass with 100% coverage
REQ-1: Wrap the task-list pagination in `get_vikunja_task_title` with a
bounded retry (tenacity, ~4 attempts, exponential backoff) covering
`APIError` and `requests.RequestException`. A genuinely missing task
still ends in the same "Could not find" ClickException.
REQ-2: On merge `HTTP 405`, fetch PR reviews; when zero `APPROVED`
reviews exist, extend the error with the self-approval explanation and
the remediation (approve via a non-author account).
REQ-3: Regression tests for both behaviors.
## Files Affected
- `src/devx/ci/auto_merge.py`
- `tests/unit/test_auto_merge.py`
## Test Plan
- Unit tests verify retry count and backoff parameters
- Unit tests verify logging output on retry
- Manual: trigger build-images workflow and verify retries visible in logs
- New tests: retry-then-success on transient APIError; retry-exhaustion
still raises; missing task still raises; 405 error includes
approvals diagnostic.
- `make pytest-cov`, `make lint-all`.
## Deploy Plan
- Merge to master
- Merge → next release publishes the package; consuming repos pick it
up on their next CI run (devx is pinned per-repo, bump via the usual
dependency PR flow).
## Rollback Plan
- Revert the merge commit
- Revert; previous behavior returns.
## Acceptance Criteria
- [x] REQ-1: Increase retry count from 3 to 5
- [x] REQ-2: Increase backoff from 5-20s to 10-60s exponential
- [x] REQ-3: Add visible logging between retry attempts (click.echo)
- [x] REQ-4: All tests pass with 100% coverage
- [x] REQ-1: Vikunja task-list retries transient API failures
- [x] REQ-2: 405 merge error reports approval state + self-approval hint
- [x] REQ-3: Regression tests added and passing
@@ -0,0 +1,31 @@
# DEVX-165: Accept deps: as valid conventional commit type
## Problem
The commit validator rejects `deps:` as a conventional commit type, causing
post-merge CI failures on grm and sso-bridge repos where automated dependency
bump PRs use `deps: bump devx...` as the commit message.
## Approach
REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
REQ-2: Update the allowed types list in the error message in
`src/devx/ci/validate_commit_msg.py`
REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
and `tests/unit/test_validate_commit_msg.py`
## Test Plan
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes
## Deploy Plan
- Merge to master → post-merge auto-publishes new devx version
- grm and sso-bridge bump devx version to pick up the fix
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
- [x] REQ-2: Update the allowed types list in the error message in
`src/devx/ci/validate_commit_msg.py`
- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
and `tests/unit/test_validate_commit_msg.py`
+51 -19
View File
@@ -1,31 +1,63 @@
# DEVX-165: Accept deps: as valid conventional commit type
# DEVX-165: Manifest-aware dependency PRs and registry cleanup protection
## Problem
The commit validator rejects `deps:` as a conventional commit type, causing
post-merge CI failures on grm and sso-bridge repos where automated dependency
bump PRs use `deps: bump devx...` as the commit message.
S03 (OBL-INFRA-548 REQ-3) requires an immutable delivery contract: infra
pins the sso-bridge release as {version, git ref, image tag, OCI digest}
in a JSON manifest. Two gaps in devx block that:
1. `create_dependency_pr` only regex-bumps a version string in
pyproject/ansible vars — it cannot update a structured manifest with
the resolved image digest, and it does not verify the producer
artifact exists before opening the PR.
2. `clean_images` deletes all but the newest N tags — a tag/digest that
infra still pins gets deleted once newer releases land, breaking
deploys.
## Approach
REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
REQ-2: Update the allowed types list in the error message in
`src/devx/ci/validate_commit_msg.py`
REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
and `tests/unit/test_validate_commit_msg.py`
REQ-1: `create_dependency_pr` gains `--manifest <path>` +
`--verify-container <owner/name>` + `--container-tag` +
`--source-ref`: before opening the PR it resolves the container tag's
OCI digest via the packages API (`manifest.json` blob sha256), then
updates manifest fields `{version, git_ref, image_tag, image_digest,
source_run_id, updated_at}` in the PR branch instead of a regex bump.
`--container-tag` decouples the image tag from the release version
(sso-bridge images tag `__init__.py.__version__`, not the git tag).
REQ-1b: `create_dependency_pr` clones the *target* repo into a tempdir
and performs all file lookups and git operations inside it. Previously
it operated on CWD — the producer repo's own checkout — so file updates
silently targeted the wrong repo and the whole dep-PR path no-oped
behind `|| echo warning`.
REQ-2: `clean_images` gains `--protect` (repeatable): named versions are
never deleted regardless of `--keep` trimming.
REQ-3: Regression tests for manifest update, digest resolution,
verify-then-PR ordering, and protect filtering.
## Files Affected
- `src/devx/ci/create_dependency_pr.py`
- `src/devx/tools/clean_images.py`
- `tests/unit/test_create_dependency_pr.py`
- `tests/unit/test_clean_images.py`
## Test Plan
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes
- New unit tests per REQ; `make pytest-cov`, `make lint-all`.
## Deploy Plan
- Merge to master → post-merge auto-publishes new devx version
- grm and sso-bridge bump devx version to pick up the fix
- Merge → devx release → consumer repos pick up via dependency PRs.
## Rollback Plan
- Revert the merge commit
- Revert; regex version bump and unprotected cleanup return.
## Acceptance Criteria
- [x] REQ-1: Add `deps` to `CONVENTIONAL_RE` in `src/devx/config.py`
- [x] REQ-2: Update the allowed types list in the error message in
`src/devx/ci/validate_commit_msg.py`
- [x] REQ-3: Add test coverage for `deps:` type in `tests/unit/test_config.py`
and `tests/unit/test_validate_commit_msg.py`
- [x] REQ-1: Manifest update + pre-PR OCI digest verification
- [x] REQ-2: `--protect` exempts versions from cleanup
- [x] REQ-3: Regression tests added and passing
+46 -9
View File
@@ -21,10 +21,12 @@ Usage:
"""
import re
import time
from pathlib import Path
from typing import Any
import click
import requests
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient
@@ -110,6 +112,27 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
)
_VIKUNJA_LOOKUP_ATTEMPTS = 4
_VIKUNJA_LOOKUP_BACKOFF = 5.0
def _list_project_tasks(client: VikunjaClient, page: int) -> list[dict[str, Any]]:
"""List Vikunja tasks with bounded retries for transient outages.
Implements REQ-1: during a Vikunja restart the tasks endpoint can briefly
return 404/502; retry a few times so title validation rides out the window
instead of stranding an otherwise-valid PR.
"""
for attempt in range(1, _VIKUNJA_LOOKUP_ATTEMPTS + 1):
try:
return list(client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE))
except (APIError, requests.RequestException):
if attempt == _VIKUNJA_LOOKUP_ATTEMPTS:
raise
time.sleep(_VIKUNJA_LOOKUP_BACKOFF * attempt)
raise AssertionError("unreachable") # pragma: no cover
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given DEVX-N identifier.
@@ -124,7 +147,7 @@ def get_vikunja_task_title(task_id: str) -> str:
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
tasks = _list_project_tasks(client, page)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
@@ -272,14 +295,28 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
# Exit cleanly — the rebase triggers a new CI run that will retry.
return
else:
raise click.ClickException(
_(
"Merge failed with HTTP {status}: {message}\n"
"Please check the PR is ready and you have merge rights.",
status=e.status,
message=e.message,
)
) from None
hint = ""
if e.status == 405:
# Implements: REQ-2 — surface approval state. When the PR author
# and the CI reviewer token map to the same Gitea user,
# self-approval is rejected and the merge fails 405.
try:
reviews = client.get_pr_reviews(pr_num)
if not any(r.get("state") == "APPROVED" for r in reviews):
hint = _(
"\nNo APPROVED review found on the PR. If the PR author and the"
" CI reviewer token map to the same Gitea user, self-approval is"
" rejected — approve the PR via a non-author account, then"
" re-run the auto-merge job."
)
except APIError:
pass
msg = _(
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
status=e.status,
message=e.message,
)
raise click.ClickException(msg + hint) from None
click.echo(
_(
+202 -12
View File
@@ -21,9 +21,11 @@ from __future__ import annotations
import re
import subprocess # nosec B404
from datetime import UTC, datetime
from pathlib import Path
import click
import requests
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
@@ -92,6 +94,103 @@ def update_pinned_version(file_path: str, package: str, old_version: str, new_ve
return changed
def resolve_container_digest(api_url: str, owner: str, name: str, tag: str, token: str) -> str:
"""Resolve the OCI digest for a container image tag via the packages API.
Implements REQ-1: dependency PRs must only be opened after the producer
artifact exists — this raises ClickException when the tag is missing or
the registry call fails, so the PR is never opened against an artifact
that has not been published. The sha256 of the stored ``manifest.json``
blob is the manifest content digest (what ``docker pull`` reports).
"""
url = f"{api_url}/packages/{owner}/container/{name}/{tag}/files"
headers = {"Authorization": f"token {token}"}
try:
resp = requests.get(url, headers=headers, timeout=30) # nosec B310
resp.raise_for_status()
except requests.HTTPError as e:
status = e.response.status_code if e.response is not None else "?"
raise click.ClickException(
_(
"Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). "
"Refusing to open a dependency PR for an unpublished artifact.",
owner=owner,
name=name,
tag=tag,
status=status,
)
) from e
except requests.RequestException as e:
raise click.ClickException(
_(
"Registry lookup failed for {owner}/{name}:{tag}: {error}",
owner=owner,
name=name,
tag=tag,
error=e,
)
) from e
for f in resp.json():
if f.get("name") == "manifest.json" and f.get("sha256"):
return f"sha256:{f['sha256']}"
raise click.ClickException(
_(
"Registry returned no manifest blob for {owner}/{name}:{tag}.",
owner=owner,
name=name,
tag=tag,
)
)
def update_manifest(file_path: str, section: str, fields: dict[str, str]) -> bool:
"""Update a release-manifest JSON section in place. Returns True if changed.
Implements REQ-1: manifest fields record the immutable release contract
(version, git ref, image tag, resolved OCI digest) in the target repo.
"""
import json as _json
path = Path(file_path)
if not path.exists():
raise click.ClickException(_("Manifest file not found: {file}", file=file_path))
try:
manifest = _json.loads(path.read_text(encoding="utf-8"))
except _json.JSONDecodeError as e:
raise click.ClickException(_("Manifest file {file} is not valid JSON: {error}", file=file_path, error=e)) from e
existing = manifest.get(section)
if not isinstance(existing, dict):
raise click.ClickException(
_("Manifest file {file} has no object section {section}", file=file_path, section=section)
)
changed = False
for k, v in fields.items():
if existing.get(k) != v:
existing[k] = v
changed = True
if changed:
path.write_text(_json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return changed
def read_manifest_version(file_path: str, section: str) -> str | None:
"""Read the currently pinned version from a release manifest section."""
import json as _json
path = Path(file_path)
if not path.exists():
return None
try:
manifest = _json.loads(path.read_text(encoding="utf-8"))
except _json.JSONDecodeError:
return None
existing = manifest.get(section)
if isinstance(existing, dict):
version = existing.get("version")
return str(version) if version is not None else None
return None
def create_vikunja_task(title: str, description: str) -> str | None:
"""Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531)."""
try:
@@ -112,6 +211,37 @@ def create_vikunja_task(title: str, description: str) -> str | None:
@click.option("--new-version", required=True, help=_("New version to pin"))
@click.option("--source-repo", required=True, help=_("Source repo that published (owner/name)"))
@click.option("--source-run-id", default="", help=_("CI run ID that triggered the publish"))
@click.option(
"--manifest",
"manifest_path",
default="",
help=_(
"Path to a release-manifest JSON in the target repo. When set, the PR updates "
"the manifest section named after --package instead of a regex version bump."
),
)
@click.option(
"--verify-container",
default="",
help=_(
"Container to verify before opening the PR (owner/name). Resolves the OCI "
"digest of the tag matching --new-version and records it in the manifest."
),
)
@click.option(
"--source-ref",
default="",
help=_("Git ref of the producer release (default: v<new-version>), recorded in the manifest."),
)
@click.option(
"--container-tag",
default="",
help=_(
"Container tag to verify with --verify-container (default: --new-version). "
"Use when the image tag differs from the release version, e.g. a package "
"__version__ tag vs a release git tag."
),
)
@click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR"))
def cli(
repo: str,
@@ -119,6 +249,10 @@ def cli(
new_version: str,
source_repo: str,
source_run_id: str,
manifest_path: str,
verify_container: str,
source_ref: str,
container_tag: str,
dry_run: bool,
) -> None:
"""Create an infra PR to bump a pinned dependency version."""
@@ -128,14 +262,52 @@ def cli(
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Implements: REQ-1 — verify the producer artifact exists and resolve its
# digest before any branch/PR work begins.
image_digest = ""
if verify_container:
if "/" not in verify_container:
raise click.ClickException(
_("Invalid container format: {container} (expected owner/name)", container=verify_container)
)
c_owner, c_name = verify_container.split("/", 1)
image_tag = container_tag or new_version
image_digest = resolve_container_digest(GITEA_API_URL, c_owner, c_name, image_tag, token)
click.echo(
_(
"[dep-pr] Verified {container}:{version} -> {digest}",
container=verify_container,
version=image_tag,
digest=image_digest,
)
)
# Clone the target repo — this tool runs from the *producer* repo's CI,
# so every file lookup and git operation must happen in a clone of the
# target repo, not the producer checkout in CWD.
import tempfile
workdir = Path(tempfile.mkdtemp(prefix="dep-pr-"))
clone_url = f"{GITEA_API_URL.removesuffix('/api/v1')}/{repo}.git"
auth_cfg = f"http.extraHeader=Authorization: token {token}"
subprocess.run( # nosec B603 B607
["git", "-c", auth_cfg, "clone", "--depth", "50", clone_url, str(workdir)],
check=True,
capture_output=True,
)
# Find current pinned version
old_version = None
changed_file = None
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
old_version = find_pinned_version(package, f)
if old_version:
changed_file = f
break
if manifest_path:
old_version = read_manifest_version(str(workdir / manifest_path), package)
changed_file = manifest_path if old_version else None
else:
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
old_version = find_pinned_version(package, str(workdir / f))
if old_version:
changed_file = f
break
if not old_version:
click.echo(_("[dep-pr] Could not find pinned version for {pkg} in infra repo.", pkg=package))
@@ -184,17 +356,35 @@ def cli(
else:
raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None
# Clone, update file, commit, push
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True) # nosec B603 B607
subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True) # nosec B603 B607
# Check out the API-created branch inside the target clone.
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True, cwd=workdir) # nosec B603 B607
subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True, cwd=workdir) # nosec B603 B607
if not changed_file or not update_pinned_version(changed_file, package, old_version, new_version):
if manifest_path:
fields = {
"version": new_version,
"git_ref": source_ref or f"v{new_version}",
"image_tag": container_tag or new_version,
"updated_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
if image_digest:
fields["image_digest"] = image_digest
if source_run_id:
fields["source_run_id"] = source_run_id
if not update_manifest(str(workdir / manifest_path), package, fields):
raise click.ClickException(_("Failed to update {file}", file=manifest_path))
elif not changed_file or not update_pinned_version(str(workdir / changed_file), package, old_version, new_version):
raise click.ClickException(_("Failed to update {file}", file=changed_file))
subprocess.run(["git", "add", changed_file], check=True) # nosec B603 B607
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
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
subprocess.run(["git", "commit", "-m", commit_msg], check=True) # nosec B603 B607
subprocess.run(["git", "push", "origin", branch_name], check=True) # nosec B603 B607
subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607
subprocess.run( # nosec B603 B607
["git", "-c", auth_cfg, "push", "origin", branch_name],
check=True,
cwd=workdir,
)
# Create Vikunja task for tracking
task_title = f"Bump {package} to {new_version}"
+14 -2
View File
@@ -150,15 +150,20 @@ def sort_versions_by_date(
def select_for_deletion(
versions: list[dict[str, Any]],
keep: int,
protect: frozenset[str] = frozenset(),
) -> list[dict[str, Any]]:
"""Select versions to delete, keeping the most recent ``keep`` versions.
Versions named ``latest`` are always preserved.
Versions named ``latest`` and any version listed in ``protect`` are
always preserved. Implements REQ-2 (DEVX-165): tags/digests that a
release manifest still pins must survive registry cleanup.
"""
sorted_versions = sort_versions_by_date(versions)
to_delete = sorted_versions[keep:]
# Always preserve 'latest' tag
to_delete = [v for v in to_delete if v.get("version") != "latest"]
# Preserve explicitly protected versions (e.g., pinned by a release manifest)
to_delete = [v for v in to_delete if v.get("version") not in protect]
return to_delete
@@ -182,6 +187,12 @@ def select_for_deletion(
show_default=True,
help="Number of recent versions to keep (excluding 'latest').",
)
@click.option(
"--protect",
"protect",
multiple=True,
help="Version/tag to never delete (e.g., pinned by a release manifest). Can be repeated.",
)
@click.option(
"--dry-run",
is_flag=True,
@@ -197,6 +208,7 @@ def main(
owner: str | None,
names: tuple[str, ...],
keep: int,
protect: tuple[str, ...],
dry_run: bool,
api_url: str | None,
) -> None:
@@ -238,7 +250,7 @@ def main(
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
)
to_delete = select_for_deletion(versions, keep)
to_delete = select_for_deletion(versions, keep, frozenset(protect))
kept_count = len(versions) - len(to_delete)
click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
+104
View File
@@ -127,6 +127,14 @@
"ru": "\nОтсутствующая документация:",
"zh": "\n缺失的文档:"
},
"\nNo APPROVED review found on the PR. If the PR author and the CI reviewer token map to the same Gitea user, self-approval is rejected — approve the PR via a non-author account, then re-run the auto-merge job.": {
"bg": "\nВ PR не е намерено ревю APPROVED. Ако авторът на PR и токенът на CI рецензента са един и същ потребител в Gitea, самоодобрението се отхвърля — одобрете PR чрез друг акаунт и стартирайте отново задачата за автоматично сливане.",
"de": "\nKein APPROVED-Review im PR gefunden. Wenn der PR-Autor und das CI-Reviewer-Token demselben Gitea-Benutzer entsprechen, wird die Selbstgenehmigung abgelehnt — genehmigen Sie den PR über ein anderes Konto und führen Sie den Auto-Merge-Job erneut aus.",
"en": "\nNo APPROVED review found on the PR. If the PR author and the CI reviewer token map to the same Gitea user, self-approval is rejected — approve the PR via a non-author account, then re-run the auto-merge job.",
"pl": "\nNie znaleziono recenzji APPROVED w PR. Jeśli autor PR i token recenzenta CI mapują na tego samego użytkownika Gitea, samoakceptacja jest odrzucana — zatwierdź PR za pomocą innego konta, a następnie ponownie uruchom zadanie automatycznego scalania.",
"ru": "\nВ PR не найдено ревью APPROVED. Если автор PR и токен CI-ревьюера принадлежат одному и тому же пользователю Gitea, самоодобрение отклоняется — одобрите PR через другой аккаунт, затем повторно запустите задачу автоматического слияния.",
"zh": "\n在 PR 中未找到 APPROVED 评审。如果 PR 作者和 CI 评审者令牌映射到同一个 Gitea 用户,自我批准将被拒绝 — 请通过非作者账户批准该 PR,然后重新运行自动合并任务。"
},
"\nNo stale version references found.": {
"bg": "",
"de": "",
@@ -1335,6 +1343,30 @@
"ru": "Настройка входа tea '{name}' для {url}...",
"zh": "正在为 {url} 配置 tea 登录 '{name}'..."
},
"Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.": {
"bg": "Артефактът на контейнера {owner}/{name}:{tag} не е намерен или не може да бъде прочетен (HTTP {status}). Отказвам да отворя PR за зависимост за непубликуван артефакт.",
"de": "Container-Artefakt {owner}/{name}:{tag} nicht gefunden oder nicht lesbar (HTTP {status}). Kein Dependency-PR für ein unveröffentlichtes Artefakt.",
"en": "Container artifact {owner}/{name}:{tag} not found or unreadable (HTTP {status}). Refusing to open a dependency PR for an unpublished artifact.",
"pl": "Artefakt kontenera {owner}/{name}:{tag} nie został znaleziony lub jest nieczytelny (HTTP {status}). Odmawiam otwarcia PR zależności dla nieopublikowanego artefaktu.",
"ru": "Артефакт контейнера {owner}/{name}:{tag} не найден или недоступен для чтения (HTTP {status}). Отказ открывать PR зависимости для неопубликованного артефакта.",
"zh": "容器构件 {owner}/{name}:{tag} 未找到或不可读 (HTTP {status})。拒绝为未发布的构件创建依赖 PR。"
},
"Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.": {
"bg": "Таг на контейнер за проверка с --verify-container (по подразбиране: --new-version). Използвайте, когато тагът на изображението се различава от версията на изданието, напр. таг на __version__ на пакет срещу git таг на издание.",
"de": "Container-Tag, der mit --verify-container geprüft wird (Standard: --new-version). Zu verwenden, wenn sich das Image-Tag von der Release-Version unterscheidet, z. B. ein __version__-Tag eines Pakets vs. ein Release-Git-Tag.",
"en": "Container tag to verify with --verify-container (default: --new-version). Use when the image tag differs from the release version, e.g. a package __version__ tag vs a release git tag.",
"pl": "Tag kontenera do weryfikacji z --verify-container (domyślnie: --new-version). Użyj, gdy tag obrazu różni się od wersji wydania, np. tag __version__ pakietu a tag git wydania.",
"ru": "Тег контейнера для проверки с --verify-container (по умолчанию: --new-version). Используйте, когда тег образа отличается от версии релиза, например тег __version__ пакета против git-тега релиза.",
"zh": "用 --verify-container 验证的容器标签(默认:--new-version)。当镜像标签与发布版本不同时使用,例如包的 __version__ 标签与发布 git 标签。"
},
"Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version and records it in the manifest.": {
"bg": "Контейнер за проверка преди отваряне на PR (owner/name). Разрешава OCI дайджеста на тага, съвпадащ с --new-version, и го записва в манифеста.",
"de": "Container, der vor dem Öffnen des PR verifiziert wird (owner/name). Ermittelt den OCI-Digest des zu --new-version passenden Tags und trägt ihn ins Manifest ein.",
"en": "Container to verify before opening the PR (owner/name). Resolves the OCI digest of the tag matching --new-version and records it in the manifest.",
"pl": "Kontener do zweryfikowania przed otwarciem PR (owner/name). Rozwiązuje skrót OCI tagu pasującego do --new-version i zapisuje go w manifeście.",
"ru": "Контейнер для проверки перед открытием PR (owner/name). Разрешает OCI-дайджест тега, соответствующего --new-version, и записывает его в манифест.",
"zh": "在打开 PR 前要验证的容器 (owner/name)。解析与 --new-version 匹配的标签的 OCI 摘要并记录到清单中。"
},
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
"bg": "Не може да се определи номерът на PR. Използвайте --pr, за да го зададете изрично,\nили изпълнете командата от клон с отворен PR.",
"de": "PR-Nummer konnte nicht ermittelt werden. Mit --pr explizit angeben,\noder den Befehl von einem Branch mit offenem PR ausführen.",
@@ -1815,6 +1847,14 @@
"ru": "Генерация значков в {out}...",
"zh": "正在 {out} 中生成徽章..."
},
"Git ref of the producer release (default: v<new-version>), recorded in the manifest.": {
"bg": "Git референция на продуцентското издание (по подразбиране: v<new-version>), записана в манифеста.",
"de": "Git-Ref des Producer-Releases (Standard: v<new-version>), im Manifest verzeichnet.",
"en": "Git ref of the producer release (default: v<new-version>), recorded in the manifest.",
"pl": "Referencja git wydania producenta (domyślnie: v<new-version>), zapisana w manifeście.",
"ru": "Git-ссылка релиза производителя (по умолчанию: v<new-version>), записанная в манифест.",
"zh": "生产者发布的 git 引用(默认:v<new-version>),记录在清单中。"
},
"Git tag or ref that was deployed": {
"bg": "Git таг или референция, която беше разгърната",
"de": "Git-Tag oder Ref, der bereitgestellt wurde",
@@ -1983,6 +2023,14 @@
"ru": "Интеграционные тесты пройдены.",
"zh": "集成测试通过。"
},
"Invalid container format: {container} (expected owner/name)": {
"bg": "Невалиден формат на контейнер: {container} (очаква се owner/name)",
"de": "Ungültiges Container-Format: {container} (erwartet owner/name)",
"en": "Invalid container format: {container} (expected owner/name)",
"pl": "Nieprawidłowy format kontenera: {container} (oczekiwano owner/name)",
"ru": "Неверный формат контейнера: {container} (ожидается owner/name)",
"zh": "容器格式无效:{container}(应为 owner/name"
},
"Invalid repo format: {repo}": {
"bg": "Невалиден формат на репозитория: {repo}",
"de": "Ungültiges Repo-Format: {repo}",
@@ -2063,6 +2111,14 @@
"ru": "Цикл с {count} итерациями в тесте '{test}' — используйте property-based тестирование (hypothesis) или уменьшите до <= {max} итераций.",
"zh": "测试 '{test}' 中有 {count} 次迭代的循环 — 考虑使用基于属性的测试 (hypothesis) 或减少到 <= {max} 次迭代。"
},
"Manifest file not found: {file}": {
"bg": "Файлът на манифеста не е намерен: {file}",
"de": "Manifest-Datei nicht gefunden: {file}",
"en": "Manifest file not found: {file}",
"pl": "Nie znaleziono pliku manifestu: {file}",
"ru": "Файл манифеста не найден: {file}",
"zh": "未找到清单文件:{file}"
},
"Manifest file not found: {path}": {
"bg": "Файлът на манифеста не е намерен: {path}",
"de": "Manifestdatei nicht gefunden: {path}",
@@ -2071,6 +2127,22 @@
"ru": "Файл манифеста не найден: {path}",
"zh": "未找到清单文件:{path}"
},
"Manifest file {file} has no object section {section}": {
"bg": "Файлът на манифеста {file} няма обектна секция {section}",
"de": "Manifest-Datei {file} hat keinen Objektabschnitt {section}",
"en": "Manifest file {file} has no object section {section}",
"pl": "Plik manifestu {file} nie ma sekcji obiektu {section}",
"ru": "Файл манифеста {file} не содержит объектного раздела {section}",
"zh": "清单文件 {file} 没有对象节 {section}"
},
"Manifest file {file} is not valid JSON: {error}": {
"bg": "Файлът на манифеста {file} не е валиден JSON: {error}",
"de": "Manifest-Datei {file} ist kein gültiges JSON: {error}",
"en": "Manifest file {file} is not valid JSON: {error}",
"pl": "Plik manifestu {file} nie jest prawidłowym JSON: {error}",
"ru": "Файл манифеста {file} не является допустимым JSON: {error}",
"zh": "清单文件 {file} 不是有效的 JSON{error}"
},
"Manifest must be a JSON list": {
"bg": "Манифестът трябва да е JSON списък",
"de": "Manifest muss eine JSON-Liste sein",
@@ -2663,6 +2735,14 @@
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
},
"Path to a release-manifest JSON in the target repo. When set, the PR updates the manifest section named after --package instead of a regex version bump.": {
"bg": "Път към release-manifest JSON в целевото хранилище. Когато е зададен, PR актуализира секцията на манифеста, наречена след --package, вместо regex bump на версията.",
"de": "Pfad zu einer Release-Manifest-JSON im Ziel-Repo. Wenn gesetzt, aktualisiert der PR den nach --package benannten Manifest-Abschnitt statt eines Regex-Versionsbumps.",
"en": "Path to a release-manifest JSON in the target repo. When set, the PR updates the manifest section named after --package instead of a regex version bump.",
"pl": "Ścieżka do pliku JSON manifestu wydania w docelowym repozytorium. Po ustawieniu PR aktualizuje sekcję manifestu nazwaną po --package zamiast podbicia wersji regexem.",
"ru": "Путь к JSON манифеста релиза в целевом репозитории. Если задан, PR обновляет раздел манифеста, названный по --package, вместо повышения версии по regex.",
"zh": "目标仓库中发布清单 JSON 的路径。设置后,PR 更新以 --package 命名的清单节,而不是正则版本提升。"
},
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
"bg": "Път до pyproject.toml (по подразбиране: pyproject.toml в CWD).",
"de": "Pfad zu pyproject.toml (Standard: pyproject.toml im CWD).",
@@ -2879,6 +2959,22 @@
"ru": "Вход в реестр не удался: {error}",
"zh": "注册表登录失败:{error}"
},
"Registry lookup failed for {owner}/{name}:{tag}: {error}": {
"bg": "Неуспешно търсене в регистъра за {owner}/{name}:{tag}: {error}",
"de": "Registry-Abfrage für {owner}/{name}:{tag} fehlgeschlagen: {error}",
"en": "Registry lookup failed for {owner}/{name}:{tag}: {error}",
"pl": "Wyszukiwanie w rejestrze nie powiodło się dla {owner}/{name}:{tag}: {error}",
"ru": "Ошибка поиска в реестре для {owner}/{name}:{tag}: {error}",
"zh": "注册表查询 {owner}/{name}:{tag} 失败:{error}"
},
"Registry returned no manifest blob for {owner}/{name}:{tag}.": {
"bg": "Регистърът не върна manifest blob за {owner}/{name}:{tag}.",
"de": "Registry hat keinen Manifest-Blob für {owner}/{name}:{tag} zurückgegeben.",
"en": "Registry returned no manifest blob for {owner}/{name}:{tag}.",
"pl": "Rejestr nie zwrócił blobu manifestu dla {owner}/{name}:{tag}.",
"ru": "Реестр не вернул blob манифеста для {owner}/{name}:{tag}.",
"zh": "注册表未返回 {owner}/{name}:{tag} 的清单 blob。"
},
"Regular merge commit — running all post-merge jobs.": {
"bg": "Обикновен merge комит — изпълняват се всички post-merge задачи.",
"de": "Regulärer Merge-Commit — alle Post-Merge-Jobs werden ausgeführt.",
@@ -3719,6 +3815,14 @@
"ru": "[dep-pr] PR уже существует: #{number}",
"zh": "[dep-pr] PR 已存在:#{number}"
},
"[dep-pr] Verified {container}:{version} -> {digest}": {
"bg": "[dep-pr] Проверено {container}:{version} -> {digest}",
"de": "[dep-pr] Verifiziert {container}:{version} -> {digest}",
"en": "[dep-pr] Verified {container}:{version} -> {digest}",
"pl": "[dep-pr] Zweryfikowano {container}:{version} -> {digest}",
"ru": "[dep-pr] Проверено {container}:{version} -> {digest}",
"zh": "[dep-pr] 已验证 {container}:{version} -> {digest}"
},
"[dep-pr] {pkg} already at {version} — no PR needed.": {
"bg": "[dep-pr] {pkg} вече е на {version} — не е нужен PR.",
"de": "[dep-pr] {pkg} bereits auf {version} — kein PR nötig.",
+135
View File
@@ -468,3 +468,138 @@ def test_main_module_block() -> None:
exec(compile(source, am.__file__, "exec"), namespace)
# Verify main is callable
assert callable(namespace["main"])
# -- DEVX-164: Vikunja outage resilience + self-approval diagnostics --
class TestVikunjaLookupRetry:
"""REQ-1: transient Vikunja API failures are retried, not fatal."""
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.time.sleep")
@patch("devx.ci.auto_merge.VikunjaClient")
def test_retries_transient_api_error_then_succeeds(self, mock_client_cls: MagicMock, mock_sleep: MagicMock) -> None:
"""A 404/502 during a Vikunja restart is retried until tasks list."""
mock_client = MagicMock()
mock_client.list_project_tasks.side_effect = [
APIError(404, "Not Found"),
APIError(502, "Bad Gateway"),
[{"id": 1, "identifier": "DEVX-19", "title": "Add new feature"}],
]
mock_client_cls.return_value = mock_client
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
assert mock_client.list_project_tasks.call_count == 3
assert mock_sleep.call_count == 2
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.time.sleep")
@patch("devx.ci.auto_merge.VikunjaClient")
def test_retry_exhaustion_propagates_error(self, mock_client_cls: MagicMock, _mock_sleep: MagicMock) -> None:
"""Persistent outage still fails after the bounded attempt count."""
mock_client = MagicMock()
mock_client.list_project_tasks.side_effect = APIError(502, "Bad Gateway")
mock_client_cls.return_value = mock_client
with pytest.raises(APIError, match="Bad Gateway"):
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
assert mock_client.list_project_tasks.call_count == 4
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.time.sleep")
@patch("devx.ci.auto_merge.VikunjaClient")
def test_retries_connection_error(self, mock_client_cls: MagicMock, mock_sleep: MagicMock) -> None:
"""Connection-level failures during restart are also retried."""
import requests as req
mock_client = MagicMock()
mock_client.list_project_tasks.side_effect = [
req.ConnectionError("refused"),
[{"id": 1, "identifier": "DEVX-19", "title": "Found me"}],
]
mock_client_cls.return_value = mock_client
validate_pr_title_matches_vikunja("DEVX-19: Found me", "DEVX-19")
assert mock_sleep.call_count == 1
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.VikunjaClient")
def test_missing_task_still_fails_without_retry_sleep(self, mock_client_cls: MagicMock) -> None:
"""A healthy Vikunja that simply lacks the task fails as before."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
class TestMergeApprovalDiagnostics:
"""REQ-2: merge 405 reports approval state + self-approval remediation."""
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_405_without_approvals_shows_self_approval_hint(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
mock_client.get_pr_reviews.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "Merge failed" in result.output
assert "APPROVED" in result.output
assert "non-author account" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_405_with_approvals_omits_hint(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
mock_client.get_pr_reviews.return_value = [{"state": "APPROVED", "user": {"login": "kireto"}}]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "Merge failed" in result.output
assert "non-author account" not in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_405_reviews_fetch_failure_still_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""If the reviews lookup itself fails, the merge error still surfaces."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
mock_client.get_pr_reviews.side_effect = APIError(403, "Forbidden")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "Merge failed" in result.output
+32
View File
@@ -485,6 +485,38 @@ class TestSelectForDeletion:
to_delete = select_for_deletion(versions, keep=5)
assert len(to_delete) == 0
def test_protect_exempts_version(self) -> None:
"""REQ-2: a manifest-pinned version survives cleanup beyond --keep."""
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2, protect=frozenset({"0.1.0"}))
deleted = {v["version"] for v in to_delete}
assert "0.1.0" not in deleted
assert deleted == {"0.2.0"}
def test_protect_multiple_versions(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=1, protect=frozenset({"0.1.0", "0.2.0"}))
assert {v["version"] for v in to_delete} == {"0.3.0"}
def test_protect_default_empty_behaves_as_before(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert {v["version"] for v in to_delete} == {"0.1.0"}
class TestCleanImagesAPI:
"""Tests for the clean_images module's API functions."""
+280
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from devx.ci.create_dependency_pr import (
@@ -15,6 +16,13 @@ from devx.ci.create_dependency_pr import (
)
@pytest.fixture(autouse=True)
def _mock_subprocess() -> MagicMock:
"""Mock subprocess so CLI tests never run a real git clone."""
with patch("devx.ci.create_dependency_pr.subprocess.run") as m:
yield m
class TestFindPinnedVersion:
def test_finds_pip_git_pin(self, tmp_path: Path) -> None:
content = "grm @ git+https://git.example.com/repo.git@v0.5.1"
@@ -180,3 +188,275 @@ class TestCreateVikunjaTask:
mock_client.create_task.return_value = {"identifier": "OBL-INFRA-999"}
result = create_vikunja_task("Test", "desc")
assert result == "OBL-INFRA-999"
class TestResolveContainerDigest:
"""REQ-1: pre-PR artifact verification via the packages API."""
def test_returns_digest_from_manifest_blob(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [
{"name": "sha256_layer", "sha256": "abc"},
{"name": "manifest.json", "sha256": "deadbeef"},
]
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
digest = resolve_container_digest(
"https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok"
)
assert digest == "sha256:deadbeef"
def test_raises_when_version_missing(self) -> None:
import requests
from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock()
http_err = requests.HTTPError("404")
http_err.response = MagicMock(status_code=404)
mock_resp.raise_for_status.side_effect = http_err
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
with pytest.raises(click.ClickException, match="unpublished artifact"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "9.9.9", "tok")
def test_raises_when_no_manifest_blob(self) -> None:
from devx.ci.create_dependency_pr import resolve_container_digest
mock_resp = MagicMock()
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = [{"name": "sha256_layer", "sha256": "abc"}]
with patch("devx.ci.create_dependency_pr.requests.get", return_value=mock_resp):
with pytest.raises(click.ClickException, match="no manifest blob"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
def test_raises_on_connection_error(self) -> None:
import requests
from devx.ci.create_dependency_pr import resolve_container_digest
with patch(
"devx.ci.create_dependency_pr.requests.get",
side_effect=requests.ConnectionError("refused"),
):
with pytest.raises(click.ClickException, match="Registry lookup failed"):
resolve_container_digest("https://git.example.com/api/v1", "oblachno", "sso-bridge", "0.9.1", "tok")
class TestManifestHelpers:
"""REQ-1: manifest read/update helpers."""
def test_read_version(self, tmp_path: Path) -> None:
import json
from devx.ci.create_dependency_pr import read_manifest_version
p = tmp_path / "m.json"
p.write_text(json.dumps({"schema_version": 1, "sso_bridge": {"version": "0.9.0"}}))
assert read_manifest_version(str(p), "sso_bridge") == "0.9.0"
def test_read_version_missing_file(self, tmp_path: Path) -> None:
from devx.ci.create_dependency_pr import read_manifest_version
assert read_manifest_version(str(tmp_path / "nope.json"), "sso_bridge") is None
def test_read_version_bad_json(self, tmp_path: Path) -> None:
from devx.ci.create_dependency_pr import read_manifest_version
p = tmp_path / "m.json"
p.write_text("not json{")
assert read_manifest_version(str(p), "sso_bridge") is None
def test_read_version_missing_section(self, tmp_path: Path) -> None:
import json
from devx.ci.create_dependency_pr import read_manifest_version
p = tmp_path / "m.json"
p.write_text(json.dumps({"schema_version": 1, "other": "x"}))
assert read_manifest_version(str(p), "sso_bridge") is None
def test_update_manifest_fields(self, tmp_path: Path) -> None:
import json
from devx.ci.create_dependency_pr import update_manifest
p = tmp_path / "m.json"
p.write_text(json.dumps({"schema_version": 1, "sso_bridge": {"version": "0.9.0"}}))
changed = update_manifest(
str(p),
"sso_bridge",
{"version": "0.9.1", "git_ref": "v0.9.1", "image_digest": "sha256:x"},
)
assert changed is True
data = json.loads(p.read_text())
assert data["sso_bridge"]["version"] == "0.9.1"
assert data["sso_bridge"]["git_ref"] == "v0.9.1"
assert data["sso_bridge"]["image_digest"] == "sha256:x"
def test_update_manifest_no_change(self, tmp_path: Path) -> None:
import json
from devx.ci.create_dependency_pr import update_manifest
p = tmp_path / "m.json"
p.write_text(json.dumps({"sso_bridge": {"version": "0.9.1"}}))
assert update_manifest(str(p), "sso_bridge", {"version": "0.9.1"}) is False
def test_update_manifest_missing_file(self, tmp_path: Path) -> None:
from devx.ci.create_dependency_pr import update_manifest
with pytest.raises(click.ClickException, match="not found"):
update_manifest(str(tmp_path / "nope.json"), "sso_bridge", {"version": "1"})
def test_update_manifest_bad_json(self, tmp_path: Path) -> None:
from devx.ci.create_dependency_pr import update_manifest
p = tmp_path / "m.json"
p.write_text("broken{")
with pytest.raises(click.ClickException, match="not valid JSON"):
update_manifest(str(p), "sso_bridge", {"version": "1"})
def test_update_manifest_missing_section(self, tmp_path: Path) -> None:
import json
from devx.ci.create_dependency_pr import update_manifest
p = tmp_path / "m.json"
p.write_text(json.dumps({"schema_version": 1}))
with pytest.raises(click.ClickException, match="no object section"):
update_manifest(str(p), "sso_bridge", {"version": "1"})
class TestCliManifestMode:
@patch("devx.ci.create_dependency_pr.read_manifest_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_manifest_same_version_no_pr(self, mock_token: MagicMock, mock_read: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_read.return_value = "0.9.1"
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"sso_bridge",
"--new-version",
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/sso-bridge-release.json",
],
)
assert result.exit_code == 0
assert "no pr needed" in result.output.lower()
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
@patch("devx.ci.create_dependency_pr.read_manifest_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_verify_container_runs_before_lookup(
self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock
) -> None:
"""Verification failure aborts before the version lookup/PR steps."""
mock_token.return_value = "fake-token"
mock_digest.side_effect = click.ClickException("unpublished artifact")
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"sso_bridge",
"--new-version",
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/m.json",
"--verify-container",
"oblachno/sso-bridge",
],
)
assert result.exit_code != 0
mock_read.assert_not_called()
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
@patch("devx.ci.create_dependency_pr.read_manifest_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_verify_container_resolves_digest(
self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock
) -> None:
mock_token.return_value = "fake-token"
mock_read.return_value = "0.9.1"
mock_digest.return_value = "sha256:abc"
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"sso_bridge",
"--new-version",
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/m.json",
"--verify-container",
"oblachno/sso-bridge",
],
)
assert result.exit_code == 0
mock_digest.assert_called_once()
args = mock_digest.call_args[0]
assert args[1:4] == ("oblachno", "sso-bridge", "0.9.1")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_verify_container_invalid_format(self, mock_token: MagicMock) -> None:
mock_token.return_value = "fake-token"
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"sso_bridge",
"--new-version",
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--verify-container",
"no-slash",
],
)
assert result.exit_code != 0
@patch("devx.ci.create_dependency_pr.resolve_container_digest")
@patch("devx.ci.create_dependency_pr.read_manifest_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_container_tag_overrides_new_version(
self, mock_token: MagicMock, mock_read: MagicMock, mock_digest: MagicMock
) -> None:
"""--container-tag selects the image tag when it differs from version."""
mock_token.return_value = "fake-token"
mock_read.return_value = "0.9.1"
mock_digest.return_value = "sha256:abc"
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"sso_bridge",
"--new-version",
"0.9.1",
"--source-repo",
"oblachno/sso-bridge",
"--manifest",
"deploy/m.json",
"--verify-container",
"oblachno/sso-bridge",
"--container-tag",
"0.2.4",
],
)
assert result.exit_code == 0
args = mock_digest.call_args[0]
assert args[1:4] == ("oblachno", "sso-bridge", "0.2.4")
+121
View File
@@ -0,0 +1,121 @@
"""Pytest tests for Devin skill validation.
Validates that all skills in .devin/skills/ are well-formed: H1 title,
"when to invoke" section, prerequisites when commands are referenced,
make-target references that exist, and file references that exist.
Run with: make pytest TEST=tests/test_skills_validation.py
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
# Sections required for every skill
REQUIRED_SECTIONS = ["when to invoke"]
# Sections required only for skills that reference commands/tools
COMMAND_REQUIRED_SECTIONS = ["prerequisites"]
# Markers indicating a skill references commands/tools
COMMAND_MARKERS = ("`make ", "```bash", "```sh", "curl ", "python ", "python3 ", "ssh ")
EXPECTED_SKILLS = [
"dependency-graph",
"deployment-coordination",
"devx-workflow",
"pr-review",
"skill-creation",
"spec-driven-development",
"testing-and-debugging",
"vikunja-tasks",
]
def _find_skills() -> dict[str, Path]:
skills_dir = REPO_ROOT / ".devin" / "skills"
assert skills_dir.exists(), ".devin/skills/ directory not found"
return {d.name: d / "SKILL.md" for d in skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()}
# Skills shared with other repos — file-path references are only checked
# in the owning repo (infra), where the referenced files live.
SHARED_SKILLS = {"cross-repo-sync", "branch-hygiene", "dependency-graph", "skill-creation"}
def _make_targets() -> set[str]:
"""Collect make targets from Makefile plus included devx .mak files."""
targets: set[str] = set()
makefile = REPO_ROOT / "Makefile"
if makefile.exists():
targets.update(re.findall(r"^([a-zA-Z][a-zA-Z0-9_-]*):", makefile.read_text(), re.MULTILINE))
for mak in REPO_ROOT.glob(".venv/lib/python*/site-packages/devx/make/*.mak"):
targets.update(re.findall(r"^([a-zA-Z][a-zA-Z0-9_-]*):", mak.read_text(), re.MULTILINE))
# devx repo: devx.mak lives in the package source (editable install)
for mak in REPO_ROOT.glob("src/devx/make/*.mak"):
targets.update(re.findall(r"^([a-zA-Z][a-zA-Z0-9_-]*):", mak.read_text(), re.MULTILINE))
return targets
def _validate_skill(skill_name: str, skill_path: Path, make_targets: set[str]) -> list[str]:
"""Validate a single skill file. Returns list of error messages."""
errors: list[str] = []
content = skill_path.read_text()
if not re.search(r"^# ", content, re.MULTILINE):
errors.append(f"{skill_name}: missing H1 title")
lower = content.lower()
for section in REQUIRED_SECTIONS:
if f"## {section}" not in lower:
errors.append(f"{skill_name}: missing '## {section.title()}' section")
references_commands = any(marker in content for marker in COMMAND_MARKERS)
if references_commands:
for section in COMMAND_REQUIRED_SECTIONS:
if f"## {section}" not in lower:
errors.append(
f"{skill_name}: missing '## {section.title()}' section "
"(required because skill references commands/tools)"
)
for target in re.findall(r"`make ([a-zA-Z][a-zA-Z0-9_-]*)`", content):
if target not in make_targets:
errors.append(f"{skill_name}: references `make {target}` but target does not exist")
# File-path checks: skip shared skills (checked in infra) and
# placeholder paths containing <...> templates.
if skill_name not in SHARED_SKILLS:
for match in re.findall(r"`((?:scripts|src|ansible|docs|tests|environments)/[^`\s]+)`", content):
if "<" in match:
continue
if not (REPO_ROOT / match).exists():
errors.append(f"{skill_name}: references `{match}` but file does not exist")
return errors
@pytest.mark.parametrize("skill_name", EXPECTED_SKILLS)
def test_skill_exists(skill_name: str) -> None:
"""Each expected skill must have a SKILL.md."""
skill = REPO_ROOT / ".devin" / "skills" / skill_name / "SKILL.md"
assert skill.exists(), f"{skill_name}/SKILL.md not found"
def test_minimum_skill_count() -> None:
"""The repo should carry a working set of skills, not a stub."""
assert len(_find_skills()) >= 7, "expected >=10 skills"
def test_all_skills_validate() -> None:
"""All skills must pass structure/reference validation."""
make_targets = _make_targets()
errors: list[str] = []
for skill_name, skill_path in _find_skills().items():
errors.extend(_validate_skill(skill_name, skill_path, make_targets))
assert not errors, "Skill validation failed:\n" + "\n".join(f" - {e}" for e in errors)