Compare commits

...
7 Commits
Author SHA1 Message Date
devx-ci-bot 5c4959b67a release: v0.51.12 [skip ci] 2026-09-19 02:34:17 +00:00
kireto 4ce25f16b2 DEVX-166: fix: create_dependency_pr clones target repo instead of editing producer checkout
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-09-19 02:33:30 +00:00
devx-ci-bot f0b9b71811 release: v0.51.11 [skip ci] 2026-09-19 02:05:46 +00: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 659 additions and 94 deletions
+11 -1
View File
@@ -2,11 +2,21 @@
Quick reference for devx tools when working on the devx repo itself. 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) ## PR Workflow (use these, not raw git/tea/MCP)
| Task | Command | | 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` | | Create PR | `make create-pr` |
| Push + create PR | `make push-with-pr` | | Push + create PR | `make push-with-pr` |
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` | | Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
@@ -1,5 +1,14 @@
# Spec-Driven Development # 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 ## Overview
Every change starts with a spec. No spec, no code. No code, no PR. 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 Make targets for testing, debugging, and CI investigation. **Use these
instead of raw `pytest`, `ruff`, or `actionlint` commands.** 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 ## Why Make Targets
Make targets encapsulate the correct venv activation, PYTHONPATH, env 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.
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.51.12] - 2026-09-19
### Bug Fixes
- Create_dependency_pr clones target repo instead of editing producer checkout
## [0.51.11] - 2026-09-19
### Bug Fixes
- *(ci)* Retry Vikunja lookups and surface self-approval merge failures
## [0.51.10] - 2026-09-17 ## [0.51.10] - 2026-09-17
### Bug Fixes ### Bug Fixes
+9 -9
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) [![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) [![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) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/tests.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/09be76620c0a26418c614fb26b7feec2a4f457d3/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.10", "devx>=0.51.12",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.51.10"`) or use a version constraint > `dependencies` (for example, `"devx==0.51.12"`) or use a version constraint
> (for example, `"devx>=0.51.10,<0.52"`). > (for example, `"devx>=0.51.12,<0.52"`).
### Optional extras ### Optional extras
+8 -8
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) [![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) [![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) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/tests.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/09be76620c0a26418c614fb26b7feec2a4f457d3/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![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/09be76620c0a26418c614fb26b7feec2a4f457d3/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ef6e8cf76365d1332a88437ae62a93ef63335e68/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.10", "devx>=0.51.12",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.51.10"` or `"devx>=0.51.10,<0.52"`. Pin a specific version if needed: `"devx==0.51.12"` or `"devx>=0.51.12,<0.52"`.
### Optional extras ### Optional extras
@@ -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 ## Problem
`_run_push` only checked `result.stderr` for HTTP 500, but docker push
sends the "received unexpected HTTP status: 500 Internal Server Error" The OBL-INFRA-548 programme audit found devx lacks a Vikunja
message to **stdout**, not stderr. This means the tenacity retry logic task-lifecycle skill and has no skill validation tests (infra and
added in DEVX-162 never triggered — the push failed immediately without sso-bridge have them; grm gained them under GRM-171).
retrying. `devx-workflow` documents `make create-task -- --title`, which fails
because `devx-create-task` forwards no arguments.
## Approach ## 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-1: Add `vikunja-tasks` skill: create via module call, query,
REQ-2: push_image "already exists" check uses combined stdout+stderr close, spec-collision convention, unreachable-tracker handling.
REQ-3: All existing tests pass with 100% coverage 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 ## Test Plan
- Unit tests for stdout 500 detection
- Unit tests for stderr 500 detection - `pytest tests/unit/test_skills_validation.py` passes (10 tests).
- Manual: trigger build-images workflow and verify retry works
## Deploy Plan ## Deploy Plan
- Merge to master
Documentation/skills only — auto-merge to master; no runtime deploy.
## Rollback Plan ## Rollback Plan
- Revert the merge commit
Revert the squash-merge commit; skills are inert documentation.
## Acceptance Criteria ## 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-1: `vikunja-tasks` skill exists.
- [x] REQ-3: All existing tests pass with 100% coverage - [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 ## 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 Two defects hit auto-merge during S02 work:
"offset mismatch" race condition needs more than ~15s to recover. The
current backoff is 5s-20s with 3 attempts (total ~15s of waiting). 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 ## 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-1: Wrap the task-list pagination in `get_vikunja_task_title` with a
REQ-2: Increase backoff from 5-20s to 10-60s exponential bounded retry (tenacity, ~4 attempts, exponential backoff) covering
REQ-3: Add visible logging between retry attempts (click.echo) `APIError` and `requests.RequestException`. A genuinely missing task
REQ-4: All tests pass with 100% coverage 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 ## Test Plan
- Unit tests verify retry count and backoff parameters
- Unit tests verify logging output on retry - New tests: retry-then-success on transient APIError; retry-exhaustion
- Manual: trigger build-images workflow and verify retries visible in logs still raises; missing task still raises; 405 error includes
approvals diagnostic.
- `make pytest-cov`, `make lint-all`.
## Deploy Plan ## 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 ## Rollback Plan
- Revert the merge commit
- Revert; previous behavior returns.
## Acceptance Criteria ## 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-1: Vikunja task-list retries transient API failures
- [x] REQ-3: Add visible logging between retry attempts (click.echo) - [x] REQ-2: 405 merge error reports approval state + self-approval hint
- [x] REQ-4: All tests pass with 100% coverage - [x] REQ-3: Regression tests added and passing
+35 -15
View File
@@ -1,27 +1,47 @@
# DEVX-166: Exclude docs/plans/* from PR size check # DEVX-166: create_dependency_pr must clone the target repo
## Problem ## Problem
Planning docs in `docs/plans/` are legitimately large (700+ lines) but
fail the PR size check (max 500 lines). This blocks PRs that only add `create_dependency_pr` resolves the pinned-version file and runs all
planning documents. git operations in the current working directory. Producer post-merge
workflows (grm, sso-bridge) invoke it from the *producer* checkout, so
it searches/modifies the wrong repository: `find_pinned_version` reads
files that do not exist there, and the git fetch/checkout/commit/push
sequence runs in the producer clone. The failure is silent — producer
workflows append `|| echo warning`, so a no-op looks like success.
## Approach ## Approach
REQ-1: Add `docs/plans/*` to `DEFAULT_EXCLUDED_PATTERNS` in
`src/devx/ci/check_pr_size.py` REQ-1: Clone the target repo (`--repo`) into a temporary directory with
REQ-2: Add test coverage for the new exclusion pattern an authenticated `http.extraHeader`, then run every file lookup and git
operation (fetch, checkout, add, commit, push) inside that clone. The
push uses the same auth header config.
REQ-2: Tests mock `subprocess.run` so no real clone happens in the unit
suite (test-isolation gate).
## Files Affected
- `src/devx/ci/create_dependency_pr.py`
- `tests/unit/test_create_dependency_pr.py`
## Test Plan ## Test Plan
- `make pytest-cov` passes with 100% coverage
- `make lint-all` passes - Existing CLI tests keep passing with the subprocess mock in place.
- Verify the clone command targets the `--repo` URL and that git ops
run with `cwd=<clone>` (asserted via the mock's call list).
## Deploy Plan ## Deploy Plan
- Merge to master → post-merge auto-publishes new devx version
- Infra PR #1179 picks up the fix once devx is bumped Merge via auto-merge after green CI. The fix takes effect the next time
a producer post-merge workflow invokes `create_dependency_pr`.
## Rollback Plan ## Rollback Plan
- Revert the merge commit
Revert the squash-merge commit on master; the previous (broken) CWD
behavior returns, which is strictly worse — no state is created.
## Acceptance Criteria ## Acceptance Criteria
- [x] REQ-1: Add `docs/plans/*` to `DEFAULT_EXCLUDED_PATTERNS` in
`src/devx/ci/check_pr_size.py` - [x] REQ-1: target repo cloned to tempdir; all file/git ops run in the clone
- [x] REQ-2: Add test coverage for the new exclusion pattern - [x] REQ-2: unit tests never spawn a real git subprocess
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.51.10", "devx>=0.51.12",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.51.10", "devx>=0.51.12",
] ]
``` ```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects. molecule testing helpers for Ansible projects.
""" """
__version__ = "0.51.10" __version__ = "0.51.12"
+46 -9
View File
@@ -21,10 +21,12 @@ Usage:
""" """
import re import re
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import click import click
import requests
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient 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: def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given DEVX-N identifier. """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) client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1 page = 1
while True: 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: if not tasks:
break break
matches = [t for t in tasks if t.get("identifier") == task_id] 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. # Exit cleanly — the rebase triggers a new CI run that will retry.
return return
else: else:
raise click.ClickException( hint = ""
_( if e.status == 405:
"Merge failed with HTTP {status}: {message}\n" # Implements: REQ-2 — surface approval state. When the PR author
"Please check the PR is ready and you have merge rights.", # and the CI reviewer token map to the same Gitea user,
status=e.status, # self-approval is rejected and the merge fails 405.
message=e.message, try:
) reviews = client.get_pr_reviews(pr_num)
) from None 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( click.echo(
_( _(
+25 -8
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import tempfile
from pathlib import Path from pathlib import Path
import click import click
@@ -128,11 +129,23 @@ def cli(
owner, repo_name = repo.split("/", 1) owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Implements: REQ-1 — this tool runs from the *producer* repo's CI, so
# every file lookup and git operation must happen inside a clone of the
# target repo, not the producer checkout in CWD.
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 # Find current pinned version
old_version = None old_version = None
changed_file = None changed_file = None
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]: for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
old_version = find_pinned_version(package, f) old_version = find_pinned_version(package, str(workdir / f))
if old_version: if old_version:
changed_file = f changed_file = f
break break
@@ -184,17 +197,21 @@ def cli(
else: else:
raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None
# Clone, update file, commit, push # Check out the API-created branch inside the target clone.
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True) # nosec B603 B607 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) # 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 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)) raise click.ClickException(_("Failed to update {file}", file=changed_file))
subprocess.run(["git", "add", changed_file], check=True) # nosec B603 B607 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}" 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", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607
subprocess.run(["git", "push", "origin", branch_name], check=True) # 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 # Create Vikunja task for tracking
task_title = f"Bump {package} to {new_version}" task_title = f"Bump {package} to {new_version}"
+8
View File
@@ -127,6 +127,14 @@
"ru": "\nОтсутствующая документация:", "ru": "\nОтсутствующая документация:",
"zh": "\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.": { "\nNo stale version references found.": {
"bg": "", "bg": "",
"de": "", "de": "",
+135
View File
@@ -468,3 +468,138 @@ def test_main_module_block() -> None:
exec(compile(source, am.__file__, "exec"), namespace) exec(compile(source, am.__file__, "exec"), namespace)
# Verify main is callable # Verify main is callable
assert callable(namespace["main"]) 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
+8
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click import click
import pytest
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.create_dependency_pr import ( 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():
"""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: class TestFindPinnedVersion:
def test_finds_pip_git_pin(self, tmp_path: Path) -> None: def test_finds_pip_git_pin(self, tmp_path: Path) -> None:
content = "grm @ git+https://git.example.com/repo.git@v0.5.1" content = "grm @ git+https://git.example.com/repo.git@v0.5.1"
+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)