Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0b9b71811 | ||
|
|
9b90816be4 | ||
|
|
edb9205ce6 | ||
|
|
b2ac1cd06c | ||
|
|
f90360faef |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.51.11] - 2026-09-19
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- *(ci)* Retry Vikunja lookups and surface self-approval merge failures
|
||||
|
||||
## [0.51.10] - 2026-09-17
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.51.10",
|
||||
"devx>=0.51.11",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
@@ -101,8 +101,8 @@ pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (for example, `"devx==0.51.10"`) or use a version constraint
|
||||
> (for example, `"devx>=0.51.10,<0.52"`).
|
||||
> `dependencies` (for example, `"devx==0.51.11"`) or use a version constraint
|
||||
> (for example, `"devx>=0.51.11,<0.52"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.51.10",
|
||||
"devx>=0.51.11",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
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.11"` or `"devx>=0.51.11,<0.52"`.
|
||||
|
||||
### 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
@@ -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
@@ -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
|
||||
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.51.10",
|
||||
"devx>=0.51.11",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.51.10",
|
||||
"devx>=0.51.11",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
|
||||
molecule testing helpers for Ansible projects.
|
||||
"""
|
||||
|
||||
__version__ = "0.51.10"
|
||||
__version__ = "0.51.11"
|
||||
|
||||
@@ -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(
|
||||
_(
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user