Compare commits

...
18 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
devx-ci-bot 4d57780f40 release: v0.51.10 [skip ci] 2026-09-17 09:44:23 +00:00
kireto 27ea8903eb DEVX-162: fix: read Gitea repo-variable data field; fail closed on unknown nightly status
Post-merge / detect-and-configure (push) Successful in 2m9s
Post-merge / release-and-maintain (push) Successful in 1m30s
2026-09-17 09:41:36 +00:00
gitea-actions-bot 87d46226f6 chore: update badge URLs to commit 09be7662 [skip ci] 2026-09-05 14:18:15 +00:00
emil 4638e334b5 DEVX-167: docs: add dependency-graph, deployment-coordination, and skill-creation skills
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 54s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-09-05 14:16:52 +00:00
gitea-actions-bot c6bd4e8f63 chore: update badge URLs to commit 27d2b933 [skip ci] 2026-08-26 18:12:15 +00:00
devx-ci-bot d47e3833bc release: v0.51.9 [skip ci] 2026-08-26 18:11:18 +00:00
emil c00e9e3d7b DEVX-166: fix: exclude docs/plans/* from PR size check
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 1m25s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-26 18:10:34 +00:00
gitea-actions-bot 40e95bc96f chore: update badge URLs to commit ae0cb5b8 [skip ci] 2026-08-26 18:06:00 +00:00
devx-ci-bot 3aa9681404 release: v0.51.8 [skip ci] 2026-08-26 18:04:45 +00:00
emil e96f63cb40 DEVX-165: fix: accept deps: as valid conventional commit type
Post-merge / detect-and-configure (push) Successful in 14s
Post-merge / release-and-maintain (push) Successful in 1m46s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-26 18:03:57 +00:00
gitea-actions-bot 6d6c8cceec chore: update badge URLs to commit 2f14bdfd [skip ci] 2026-08-26 14:29:30 +00:00
37 changed files with 3117 additions and 2961 deletions
+135
View File
@@ -0,0 +1,135 @@
# dependency-graph
Map of the oblachno ecosystem. Knows which repo produces what, which
repos depend on which, and the correct order for cross-repo changes.
## When to Invoke
Invoke this skill when:
- Changes span multiple repos
- A change in one repo requires version bumps in downstream repos
- Deploying infrastructure that depends on published packages/images
- Verifying the ecosystem is in a consistent state before deployment
- Determining which repos to update and in what order
## Prerequisites
- All repos cloned under `/home/emo/dev/ideas/oblachno/`
- `.env` with `DEVELOPER_GITEA_API_TOKEN` in each repo
## Ecosystem Map
```
devx (PyPI package)
/ | \
/ | \
grm sso-bridge infra
(PyPI) (PyPI+Docker) (deploys all)
| | |
v v v
infra bump infra bump staging
(auto PR) (auto PR) production
|
mattermost-oidc (Docker image)
(infra pulls :latest at deploy)
```
## Repositories
| Repo | Produces | Consumers | Release Trigger |
|------|----------|-----------|-----------------|
| `devx` | PyPI package `devx` | grm, sso-bridge, infra | User-facing changes to `src/devx/**` |
| `grm` | PyPI package `grm` | infra | User-facing changes to `src/grm/**` or `ansible/**` |
| `sso-bridge` | PyPI package `sso_bridge` + Docker image | infra | User-facing changes to `src/sso_bridge/**` or `ansible/**` |
| `infra` | Staging/production deployment | (end users) | User-facing changes + nightly gate |
| `mattermost-oidc` | Docker image `mattermost-oidc` | infra (pulls at deploy) | `Dockerfile` or `build.yml` changes |
## Dependency Chain
### devx → all repos
devx publishes to the Gitea PyPI registry. grm, sso-bridge, and infra
pin devx in `pyproject.toml`:
```toml
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@vX.Y.Z"
```
When devx publishes a new version:
1. grm, sso-bridge, and infra must bump their pinned devx version
2. This is currently manual — no auto-dependency-PR from devx
3. Each repo must `make setup` to pick up the new version
### grm → infra
grm publishes to PyPI. Its post-merge workflow auto-creates an infra
dependency PR via `devx.ci.create_dependency_pr --repo oblachno/infra
--package grm`. The PR bumps the pinned grm version in infra's
`pyproject.toml`.
### sso-bridge → infra
sso-bridge publishes to PyPI AND builds a Docker image. Its post-merge
workflow auto-creates an infra dependency PR via
`devx.ci.create_dependency_pr --repo oblachno/infra --package
sso_bridge`. The PR bumps the pinned sso_bridge version.
The Docker image is pulled by infra at deploy time (`sso-bridge:latest`).
### mattermost-oidc → infra
mattermost-oidc builds a Docker image tagged `:latest` and `:MM_VERSION`.
infra pulls `mattermost-oidc:latest` at deploy time. There is no
auto-dependency-PR — infra simply pulls the latest image.
### infra → staging/production
infra deploys to staging and production. The deployment:
1. Provisions VMs from golden images
2. Runs Ansible roles (including grm and sso-bridge roles)
3. Pulls Docker images (sso-bridge, mattermost-oidc)
4. Configures services
## Correct Order for Cross-Repo Changes
When a change spans multiple repos, follow this order:
1. **devx first** — if the change starts in devx, merge and publish devx
first. Wait for the PyPI publish job to complete.
2. **Bump devx in consumers** — in grm/sso-bridge/infra, bump the pinned
devx version, run `make setup`, verify tests pass, merge.
3. **grm/sso-bridge second** — merge and publish grm/sso-bridge. Wait
for the PyPI publish + Docker image build to complete.
4. **Auto-dependency-PRs** — grm/sso-bridge post-merge auto-creates infra
PRs to bump pinned versions. Wait for these PRs to appear.
5. **Merge infra dependency PRs** — review and merge the auto-created
infra PRs.
6. **infra last** — deploy to staging, validate, promote to production.
## State Verification Before Deployment
Before deploying infra, verify:
1. **devx version consistent** — all repos pin the same devx version
2. **grm published** — latest grm tag exists in PyPI
3. **sso-bridge published** — latest sso_bridge tag exists in PyPI
4. **sso-bridge image built** — latest sso-bridge Docker image exists
5. **mattermost-oidc image built** — latest mattermost-oidc image exists
6. **infra pins match published versions** — no stale pins
7. **Nightly gate green**`NIGHTLY_STATUS` is not `failed`
## Quick Check Commands
```bash
# Check latest devx version
curl -sS https://git.oblachno.oblachno.fyi/api/v1/repos/oblachno-oss/devx/releases/latest | python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name','?'))"
# Check pinned devx version in each repo
for repo in grm sso-bridge infra; do
echo -n "$repo: "; grep 'devx @' /home/emo/dev/ideas/oblachno/$repo/pyproject.toml | grep -oP 'v[\d.]+'
done
# Check latest sso-bridge image build
curl -sS -H "Authorization: token $DEVELOPER_GITEA_API_TOKEN" \
"https://git.oblachno.oblachno.fyi/api/v1/repos/oblachno/sso-bridge/actions/runs?per_page=5" \
| python3 -c "import json,sys; [print(r['id'],r['status'],r['conclusion']) for r in json.load(sys.stdin).get('workflow_runs',[]) if r.get('event')=='push']"
```
@@ -0,0 +1,90 @@
# deployment-coordination
How devx releases propagate to downstream repos. devx is the base
package — all other repos pin it. A devx release must complete before
consumers can bump.
## When to Invoke
Invoke this skill when:
- Changes to devx affect downstream repos (grm, sso-bridge, infra)
- Preparing a devx release that other repos depend on
- Verifying downstream repos have bumped to the latest devx version
- Coordinating a multi-repo change that starts in devx
## Prerequisites
- devx repo at `/home/emo/dev/ideas/oblachno/devx`
- `.env` with `DEVELOPER_GITEA_API_TOKEN`
- See `dependency-graph` skill for the full ecosystem map
## What devx Produces
devx publishes a Python package to the Gitea PyPI registry. Downstream
repos pin it:
```toml
"devx @ git+https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git@vX.Y.Z"
```
## Release Flow
1. PR merged to master
2. Post-merge workflow runs `devx.ci.release` — classifies changes
3. If user-facing changes: git-cliff bumps version, creates tag, pushes
4. `devx.ci.publish` builds and publishes to Gitea PyPI registry
5. Downstream repos must bump their pinned devx version
## Downstream Consumers
| Repo | Pin location | Auto-bump? |
|------|-------------|------------|
| grm | `pyproject.toml` | No — manual |
| sso-bridge | `pyproject.toml` | No — manual |
| infra | `pyproject.toml` | No — manual |
devx does NOT auto-create dependency PRs in downstream repos. Bumping
is manual: create a PR in each downstream repo to update the pinned
version.
## Coordinating a devx Change
When a change to devx affects downstream repos:
1. **Merge devx PR** — wait for post-merge publish to complete
2. **Verify publish** — check the new tag exists:
```bash
curl -sS -H "Authorization: token $DEVELOPER_GITEA_API_TOKEN" \
https://git.oblachno.oblachno.fyi/api/v1/repos/oblachno-oss/devx/releases/latest \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name','?'))"
```
3. **Bump downstream repos** — for each repo (grm, sso-bridge, infra):
- Update `devx @ ...@vX.Y.Z` in `pyproject.toml`
- Run `make setup` to install the new version
- Run `make pytest-cov` to verify compatibility
- Create and merge a PR
4. **Verify infra deploys** — after infra bumps, verify staging deploy
picks up the new devx version
## State Verification
Before starting a devx change, verify current state:
```bash
# Current devx version
grep '__version__' /home/emo/dev/ideas/oblachno/devx/src/devx/__init__.py
# What each repo pins
for repo in grm sso-bridge infra; do
echo -n "$repo pins: "
grep 'devx @' /home/emo/dev/ideas/oblachno/$repo/pyproject.toml | grep -oP 'v[\d.]+'
done
```
If pins are inconsistent across repos, bump them to the latest published
version before starting new work.
## Common Mistakes
- Merging a devx PR and immediately merging downstream PRs without
waiting for the publish job to complete
- Forgetting to bump infra (it has the most complex deploy pipeline)
- Bumping only one downstream repo when the change affects all three
+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` |
+142
View File
@@ -0,0 +1,142 @@
# skill-creation
How to create, validate, and maintain Devin skills. Skills must be
clear, succinct, and actionable — no AI slop.
## When to Invoke
Invoke this skill when:
- Creating a new skill
- Amending an existing skill
- Evaluating whether a skill is needed
- Reviewing a PR that adds or modifies skills
## Prerequisites
- Skill directory: `.devin/skills/<skill-name>/SKILL.md`
- Validator: `tests/test_skills.py` (infra) or reference to it
- Tests: `tests/unit/test_skills_validation.py` (infra)
## When to Create a Skill
Create a skill when:
- An agent struggles with a task repeatedly (branch hygiene, PR order)
- A workflow has non-obvious ordering constraints (deployment coordination)
- A task requires specific tool usage over raw commands (CI monitoring)
- Multiple agents need shared context (dependency graph)
Do NOT create a skill for:
- One-off tasks (use a spec instead)
- Tasks already covered by AGENTS.md
- Tasks that are obvious from the Makefile or README
- Tasks that change frequently (skills should be stable)
## Skill Structure
Every skill MUST have:
```markdown
# <skill-name>
One-line description of what the skill does.
## When to Invoke
2-4 bullet points describing when to use this skill.
## Prerequisites
What must exist before using the skill (venv, .env, tools).
## <Core Content>
The actual guidance. Keep it actionable.
## Verification (if applicable)
How to verify the skill's guidance works.
## Common Mistakes (if applicable)
What agents get wrong without this skill.
```
## Quality Standards
### Do
- **Be specific.** Reference exact make targets, file paths, commands.
- **Be concise.** Each section should be scannable in under 30 seconds.
- **Be actionable.** Every paragraph should tell the agent what to DO.
- **Use tables** for command reference, mappings, and comparisons.
- **Use code blocks** for commands the agent should run.
- **Link to other skills** when related (e.g., "See `dependency-graph` skill").
### Don't
- **No preamble.** Don't start with "This skill helps agents..." — just state what it does.
- **No filler.** Don't repeat information from AGENTS.md or other skills.
- **No vague advice.** "Be careful with branches" is useless. "Run `git branch --show-current` before every commit" is useful.
- **No AI slop.** Don't write "In this comprehensive guide, we will explore..." — just give the guidance.
- **No redundant sections.** If "Common Mistakes" would repeat "When to Invoke", skip it.
- **No marketing.** Don't describe the skill as "powerful" or "comprehensive".
## Scope Rules
- **One skill per concern.** Don't mix branch hygiene with CI monitoring.
- **Project-specific, not generic.** Skills reference this repo's make targets, file paths, and conventions — not abstract advice.
- **Shared skills must be identical across repos.** Use `SHARED_SKILLS` in the validator to enforce this.
- **Per-repo skills must reflect that repo's reality.** Don't copy infra-specific targets to sso-bridge.
## Automated Validation
Every skill must pass the validator (`tests/test_skills.py`). The validator checks:
1. **Structure** — H1 title, "When to Invoke" section, "Prerequisites" section
2. **Commands** — referenced `make <target>` commands exist in Makefile or devx.mak
3. **Paths** — referenced file paths exist in the repo
4. **Shared skills** — identical content across repos (SHA-256 comparison)
5. **No drift** — no references to nonexistent commands or files
Run the validator:
```bash
python3 tests/test_skills.py --repo infra --repo sso-bridge
```
## Effectiveness Evaluation
### Static Checks (automated, CI)
The validator runs in CI as part of `make pytest-cov`. A failing skill
test blocks the PR. This catches:
- Missing sections
- Invalid commands
- Broken file references
- Cross-repo drift
### Runtime Metrics (manual, periodic)
Track these signals to evaluate skill effectiveness:
- **Skill invocation frequency** — how often agents invoke the skill
- **Success rate when invoked** — did the skill prevent the mistake it targets?
- **Feedback issues** — agents create Gitea issues with `feedback` label when a skill is unclear or wrong
- **Mistake recurrence** — if agents still make the mistake the skill targets, the skill needs improvement
### Retrospective Review
Periodically (monthly or after major incidents) review skills:
1. List all skills and their last-modified dates
2. Check for feedback issues tagged `skill-improvement`
3. Verify referenced commands still exist (run validator)
4. Remove skills that are no longer relevant
5. Update skills where mistakes still recur
6. Document lessons in this skill's "Common Mistakes" section
## Creating a New Skill — Checklist
- [ ] Identify the repeated struggle or non-obvious workflow
- [ ] Check no existing skill covers it
- [ ] Write the skill following the structure above
- [ ] Run `python3 tests/test_skills.py` — must pass
- [ ] Run `make pytest-cov` — must pass with 100% coverage
- [ ] If shared across repos, copy identical content to each repo
- [ ] Add the skill to `SHARED_SKILLS` in the validator if shared
- [ ] Create PR, verify CI passes, merge
@@ -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.
+30
View File
@@ -2,6 +2,36 @@
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
### Bug Fixes
- Read Gitea repo-variable data field; fail closed on unknown nightly status
## [0.51.9] - 2026-08-26
### Bug Fixes
- Exclude docs/plans/* from PR size check
## [0.51.8] - 2026-08-26
### Bug Fixes
- Accept deps: as valid conventional commit type
## [0.51.7] - 2026-08-26
### 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)
[![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/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/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?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.51.7",
"devx>=0.51.12",
]
[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.7"`) or use a version constraint
> (for example, `"devx>=0.51.7,<0.52"`).
> `dependencies` (for example, `"devx==0.51.12"`) or use a version constraint
> (for example, `"devx>=0.51.12,<0.52"`).
### 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)
[![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/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f474ba2f81ea1a1c7e1d204f7b07652a55be2e67/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
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.51.7",
"devx>=0.51.12",
]
[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.7"` or `"devx>=0.51.7,<0.52"`.
Pin a specific version if needed: `"devx==0.51.12"` or `"devx>=0.51.12,<0.52"`.
### Optional extras
@@ -0,0 +1,41 @@
# DEVX-162: Fix registry push race condition: serialize uploads + retry on HTTP 500
## Problem
The Gitea container registry (v1.27.2) has a known race condition in
`BlobUploader.Append()` where concurrent blob uploads cause the file
offset and DB model to get out of sync, producing HTTP 500 "offset
mismatch between file and model" errors. This causes the build-images
workflow to fail intermittently when pushing runner images.
The `package_blob_upload` table accumulates stale entries from failed
uploads that worsen the problem over time.
## Approach
Two fixes in devx (a third fix — scheduled cleanup — is tracked
separately as OBL-INFRA-537):
1. Set `DOCKER_MAX_CONCURRENT_UPLOADS=1` in the build-images workflow
to serialize blob uploads and avoid the race condition.
2. Add HTTP 500 retry logic to `push_image` in `build_image.py`.
When a push fails with HTTP 500 (not "already exists"), retry up
to 3 times with exponential backoff (5s, 10s, 20s).
REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
REQ-2: push_image retries on HTTP 500 with exponential backoff
REQ-3: All existing tests pass with 100% coverage
## Test Plan
- Unit tests for retry logic (mock subprocess)
- Manual: trigger build-images workflow and verify push succeeds
## Deploy Plan
- Merge to master
## Rollback Plan
- Revert the merge commit
## Acceptance Criteria
- [x] REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
- [x] REQ-2: push_image retries on HTTP 500 with exponential backoff
- [x] REQ-3: All existing tests pass with 100% coverage
+42 -25
View File
@@ -1,41 +1,58 @@
# DEVX-162: Fix registry push race condition: serialize uploads + retry on HTTP 500
# DEVX-162: Fix Gitea repo-variable read contract and fail closed on unknown nightly status
## Problem
The Gitea container registry (v1.27.2) has a known race condition in
`BlobUploader.Append()` where concurrent blob uploads cause the file
offset and DB model to get out of sync, producing HTTP 500 "offset
mismatch between file and model" errors. This causes the build-images
workflow to fail intermittently when pushing runner images.
The `package_blob_upload` table accumulates stale entries from failed
uploads that worsen the problem over time.
`GiteaClient.get_repo_variable()` reads `body["value"]`, but the deployed
Gitea returns the variable payload in the `data` field:
```json
{"owner_id":0,"repo_id":1,"name":"NIGHTLY_STATUS","data":"passed:5842","description":""}
```
Every read therefore returns `None`. `devx.ci.nightly_gate --action check`
interprets `None` as "bootstrap — allow deploy," so a real `failed:<run>` status
is invisible and the gate is permanently fail-open. Infra nightly run 5800 set
`NIGHTLY_STATUS=passed:5800` while platform/customer integration tests were
still failing — and even a correct `failed` value would have been ignored.
Additionally, an unrecognized non-empty status currently allows deploys
(fail-open instead of fail-closed).
Verified against the live API: `POST`/`PUT` accept `{"value": ...}` and work;
only the GET response uses `data`. The earlier `DEVX-162` spec (registry push
race) is preserved as `DEVX-162-registry-push-race-historical.md`.
## Approach
Two fixes in devx (a third fix — scheduled cleanup — is tracked
separately as OBL-INFRA-537):
1. Set `DOCKER_MAX_CONCURRENT_UPLOADS=1` in the build-images workflow
to serialize blob uploads and avoid the race condition.
REQ-1: `src/devx/api_clients.py``get_repo_variable` reads `data` first
and falls back to `value` for older server/fixture compatibility. Write
path unchanged (PUT/POST `{"value": ...}` verified live: 201/204).
2. Add HTTP 500 retry logic to `push_image` in `build_image.py`.
When a push fails with HTTP 500 (not "already exists"), retry up
to 3 times with exponential backoff (5s, 10s, 20s).
REQ-2: `src/devx/ci/nightly_gate.py` — unknown non-empty status blocks the
deploy (fail closed) instead of allowing it. Unset (bootstrap) still
allows.
REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
REQ-2: push_image retries on HTTP 500 with exponential backoff
REQ-3: All existing tests pass with 100% coverage
REQ-3: Tests cover the `data` field, the `value` fallback, and fail-closed
unknown status.
## Test Plan
- Unit tests for retry logic (mock subprocess)
- Manual: trigger build-images workflow and verify push succeeds
- `pytest tests/unit/test_api_clients.py tests/unit/test_nightly_gate.py`
- `make lint-all` (ruff, pyright, bandit, translations)
## Deploy Plan
- Merge to master
Merge via auto-merge; post-merge workflow publishes a new devx package to the
Gitea PyPI registry and opens the infra dependency-bump PR automatically.
## Rollback Plan
- Revert the merge commit
Revert the commit; infra's pinned devx version keeps the previous behavior
until the dependency PR lands.
## Acceptance Criteria
- [x] REQ-1: Build-images workflow sets DOCKER_MAX_CONCURRENT_UPLOADS=1
- [x] REQ-2: push_image retries on HTTP 500 with exponential backoff
- [x] REQ-3: All existing tests pass with 100% coverage
- [x] `get_repo_variable` returns the `data` field and falls back to `value`.
- [x] Unknown nightly status exits non-zero and writes `nightly-gate-passed=false`.
- [x] Unit tests cover `data`, `value` fallback and fail-closed unknown status.
- [x] `make lint-all` and unit tests pass.
@@ -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
+31
View File
@@ -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`
+47
View File
@@ -0,0 +1,47 @@
# DEVX-166: create_dependency_pr must clone the target repo
## Problem
`create_dependency_pr` resolves the pinned-version file and runs all
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
REQ-1: Clone the target repo (`--repo`) into a temporary directory with
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
- 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
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
Revert the squash-merge commit on master; the previous (broken) CWD
behavior returns, which is strictly worse — no state is created.
## Acceptance Criteria
- [x] REQ-1: target repo cloned to tempdir; all file/git ops run in the clone
- [x] REQ-2: unit tests never spawn a real git subprocess
+64
View File
@@ -0,0 +1,64 @@
# DEVX-167: Add dependency-graph, deployment-coordination, and skill-creation skills
## Problem
Agents working across the oblachno ecosystem lack shared, persistent
context for three recurring pain points:
1. **Cross-repo dependency ordering** — agents frequently merge
downstream PRs before the upstream publish job completes, or forget
to bump infra. There is no single reference for which repo produces
what and in what order changes must propagate.
2. **devx release coordination** — devx is the base package pinned by
grm, sso-bridge, and infra. Agents repeatedly merge devx PRs and
immediately merge downstream bumps without waiting for the PyPI
publish job, or bump only one consumer when a change affects all
three.
3. **Skill quality drift** — skills are created ad hoc with inconsistent
structure, vague advice, and no automated validation reference. New
skills miss required sections, reference nonexistent make targets,
and drift across repos.
## Approach
Add three SKILL.md files under `.devin/skills/`:
REQ-1: `dependency-graph` — shared skill mapping the oblachno ecosystem
(repos, what each produces, consumers, release triggers, correct
cross-repo change order, state verification checklist)
REQ-2: `deployment-coordination` — devx-specific skill covering the
devx release flow, downstream consumers, manual bump procedure, and
common mistakes when coordinating a devx change
REQ-3: `skill-creation` — shared skill defining skill structure,
quality standards, scope rules, automated validation reference, and a
creation checklist
## Files Affected
- `.devin/skills/dependency-graph/SKILL.md` (new)
- `.devin/skills/deployment-coordination/SKILL.md` (new)
- `.devin/skills/skill-creation/SKILL.md` (new)
- `docs/specs/DEVX-167.md` (new)
## Test Plan
- Verify all three SKILL.md files follow the required structure (H1
title, When to Invoke, Prerequisites sections)
- Verify referenced make targets and file paths exist
- Run `make pytest-cov` — skill validation tests must pass
## Deploy Plan
- Merge to master; skills are consumed by agents immediately on next
invocation — no build or deploy step required
## Rollback Plan
- Revert the merge commit; remove the three skill directories
## Acceptance Criteria
- [x] REQ-1: dependency-graph skill exists with ecosystem map, repo
table, dependency chain, cross-repo change order, and state
verification checklist
- [x] REQ-2: deployment-coordination skill exists with devx release
flow, downstream consumer table, coordination steps, and common
mistakes
- [x] REQ-3: skill-creation skill exists with structure template,
quality standards, scope rules, validation reference, and
creation checklist
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.51.7",
"devx>=0.51.12",
]
[project.optional-dependencies]
dev = [
"devx>=0.51.7",
"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.
"""
__version__ = "0.51.7"
__version__ = "0.51.12"
+4 -1
View File
@@ -392,7 +392,10 @@ class GiteaClient:
"""
try:
r = self._request("GET", f"/actions/variables/{name}")
return r.json().get("value")
body = r.json()
if "data" in body:
return body["data"]
return body.get("value")
except APIError as e:
if e.status == 404:
return None
+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(
_(
+1
View File
@@ -36,6 +36,7 @@ DEFAULT_EXCLUDED_PATTERNS = [
"CHANGELOG.md",
"README.md",
"docs/index.md",
"docs/plans/*",
"*.svg",
"uv.lock",
"poetry.lock",
+25 -8
View File
@@ -21,6 +21,7 @@ from __future__ import annotations
import re
import subprocess # nosec B404
import tempfile
from pathlib import Path
import click
@@ -128,11 +129,23 @@ def cli(
owner, repo_name = repo.split("/", 1)
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
old_version = None
changed_file = None
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:
changed_file = f
break
@@ -184,17 +197,21 @@ 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 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
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}"
+9 -2
View File
@@ -97,10 +97,17 @@ def cli(repo: str, action: str, run_id: str, github_output: bool) -> None:
write_github_output("nightly-status", status)
raise click.ClickException(_("Nightly gate failed — staging deploy blocked."))
else:
click.echo(f"[nightly-gate] Unknown nightly status: {status} — allowing deploy.")
click.echo(
_(
"[nightly-gate] Unknown nightly status: {status} — blocking deploy (fail closed).",
status=status,
),
err=True,
)
if github_output:
write_github_output("nightly-gate-passed", "true")
write_github_output("nightly-gate-passed", "false")
write_github_output("nightly-status", status)
raise click.ClickException(_("Unknown nightly status — staging deploy blocked."))
elif action == "set-passed":
set_nightly_status(client, f"passed:{run_id}" if run_id else "passed")
+1 -1
View File
@@ -118,7 +118,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
" Expected: <type>: <description>\n"
" Got: {subject}\n"
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
" perf, test, ci, build, revert, BREAKING CHANGE",
" perf, test, ci, build, deps, revert, BREAKING CHANGE",
subject=subject,
)
)
+1 -1
View File
@@ -93,4 +93,4 @@ RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
# Conventional commit regex — used by validate_commit_msg.py
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|deps)(\(.+\))?: .+")
+1850 -2852
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -923,6 +923,12 @@ class TestGiteaClientActions:
)
def test_get_repo_variable_returns_value(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"data": "v0.28.1"}))
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
assert result == "v0.28.1"
def test_get_repo_variable_falls_back_to_value(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response({"value": "v0.28.1"}))
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
+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
+6
View File
@@ -26,6 +26,12 @@ class TestIsExcluded:
def test_excludes_readme(self) -> None:
assert is_excluded("README.md", ["README.md"])
def test_excludes_plans_glob(self) -> None:
assert is_excluded("docs/plans/sso-bridge-full-extraction.md", ["docs/plans/*"])
def test_does_not_exclude_specs(self) -> None:
assert not is_excluded("docs/specs/DEVX-165.md", ["docs/plans/*"])
class TestCheckSize:
def test_under_limits_passes(self) -> None:
+1
View File
@@ -38,6 +38,7 @@ class TestConfigConstants:
def test_conventional_re(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
assert CONVENTIONAL_RE.match("deps: bump devx from v0.50.2 to v0.51.0")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
+8
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():
"""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"
+11
View File
@@ -71,6 +71,17 @@ class TestCli:
assert result.exit_code != 0
assert "blocked" in result.output.lower()
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_check_unknown_status_blocks_deploy(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_client = mock_client_cls.return_value
mock_client.get_repo_variable.return_value = "garbage-value"
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "check"])
assert result.exit_code != 0
assert "fail closed" in result.output.lower()
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_set_passed(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None:
+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)
+1
View File
@@ -30,6 +30,7 @@ class TestHelpers:
assert CONVENTIONAL_RE.match("test: add tests")
assert CONVENTIONAL_RE.match("ci: update workflow")
assert CONVENTIONAL_RE.match("build: update deps")
assert CONVENTIONAL_RE.match("deps: bump devx from v0.50.2 to v0.51.0")
assert CONVENTIONAL_RE.match("revert: undo change")
def test_conventional_re_allows_scope(self) -> None: