Public Access
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
176cb89189 |
@@ -1,135 +0,0 @@
|
|||||||
# 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']"
|
|
||||||
```
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -2,21 +2,11 @@
|
|||||||
|
|
||||||
Quick reference for devx tools when working on the devx repo itself.
|
Quick reference for devx tools when working on the devx repo itself.
|
||||||
|
|
||||||
## When to Invoke
|
|
||||||
|
|
||||||
Invoke this skill when creating PRs, checking CI status, adding
|
|
||||||
labels, rebasing branches, or performing any PR lifecycle operation.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `.venv` exists (run `make setup` if not)
|
|
||||||
- `.env` with `DEVELOPER_GITEA_API_TOKEN`, `VIKUNJA_TOKEN`
|
|
||||||
|
|
||||||
## PR Workflow (use these, not raw git/tea/MCP)
|
## PR Workflow (use these, not raw git/tea/MCP)
|
||||||
|
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Create Vikunja task | `.venv/bin/python -m devx.tools.create_task --title "..." --description "..."` (make target doesn't forward args) |
|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
|
||||||
| Create PR | `make create-pr` |
|
| Create PR | `make create-pr` |
|
||||||
| Push + create PR | `make push-with-pr` |
|
| Push + create PR | `make push-with-pr` |
|
||||||
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
|
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# 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,14 +1,5 @@
|
|||||||
# Spec-Driven Development
|
# Spec-Driven Development
|
||||||
|
|
||||||
## When to Invoke
|
|
||||||
|
|
||||||
Invoke this skill when starting any change — every PR requires a spec
|
|
||||||
at `docs/specs/<TASK-ID>.md` that CI validates before merge.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- A Vikunja task ID (`DEVX-N`) — see `vikunja-tasks` skill
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Every change starts with a spec. No spec, no code. No code, no PR.
|
Every change starts with a spec. No spec, no code. No code, no PR.
|
||||||
|
|||||||
@@ -3,17 +3,6 @@
|
|||||||
Make targets for testing, debugging, and CI investigation. **Use these
|
Make targets for testing, debugging, and CI investigation. **Use these
|
||||||
instead of raw `pytest`, `ruff`, or `actionlint` commands.**
|
instead of raw `pytest`, `ruff`, or `actionlint` commands.**
|
||||||
|
|
||||||
## When to Invoke
|
|
||||||
|
|
||||||
Invoke this skill when running tests, investigating CI failures, or
|
|
||||||
linting before push. Also invoke when asked to "run tests", "check
|
|
||||||
coverage", or "debug a failure".
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- `.venv` exists (run `make setup` if not)
|
|
||||||
- Tools installed (run `make install-tools` for actionlint/act_runner)
|
|
||||||
|
|
||||||
## Why Make Targets
|
## Why Make Targets
|
||||||
|
|
||||||
Make targets encapsulate the correct venv activation, PYTHONPATH, env
|
Make targets encapsulate the correct venv activation, PYTHONPATH, env
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -77,9 +77,6 @@ jobs:
|
|||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
# Serialize blob uploads to avoid Gitea registry race condition
|
|
||||||
# (BlobUploader.Append offset mismatch — see DEVX-162).
|
|
||||||
DOCKER_MAX_CONCURRENT_UPLOADS: "1"
|
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|||||||
+7
-20
@@ -181,31 +181,18 @@ jobs:
|
|||||||
- name: Post approval review
|
- name: Post approval review
|
||||||
env:
|
env:
|
||||||
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
|
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
|
||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
# Post APPROVE review via Gitea API to satisfy branch protection.
|
# Post APPROVE review via Gitea API to satisfy branch protection
|
||||||
# Try REVIEWER_GITEA_API_TOKEN first; fall back to CI_GITEA_API_TOKEN
|
curl -s -X POST \
|
||||||
# (CI bot account) if the reviewer token is the same user as the PR
|
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||||
# creator (Gitea rejects self-approvals).
|
-H "Authorization: token ${REVIEWER_GITEA_API_TOKEN}" \
|
||||||
for TOKEN in "${REVIEWER_GITEA_API_TOKEN}" "${CI_GITEA_API_TOKEN}"; do
|
-H "Content-Type: application/json" \
|
||||||
[ -z "$TOKEN" ] && continue
|
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}' \
|
||||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|| echo "::warning::Failed to post approval review (best-effort)."
|
||||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}')
|
|
||||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$RESPONSE" | head -n -1)
|
|
||||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
|
|
||||||
echo "Approval posted successfully (HTTP $HTTP_CODE)."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "::warning::Approval with token failed (HTTP $HTTP_CODE): ${BODY}"
|
|
||||||
done
|
|
||||||
- name: Squash merge with task ID
|
- name: Squash merge with task ID
|
||||||
env:
|
env:
|
||||||
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
|
|||||||
@@ -2,70 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [0.51.12] - 2026-09-19
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Create_dependency_pr clones target repo instead of editing producer checkout
|
|
||||||
|
|
||||||
## [0.51.11] - 2026-09-19
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- *(ci)* Retry Vikunja lookups and surface self-approval merge failures
|
|
||||||
|
|
||||||
## [0.51.10] - 2026-09-17
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
- Increase HTTP 500 retry count to 5 with longer backoff and visible logging
|
|
||||||
|
|
||||||
## [0.51.6] - 2026-08-26
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Use stderr=STDOUT to capture all docker push output in one stream
|
|
||||||
|
|
||||||
## [0.51.5] - 2026-08-26
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Check stdout for HTTP 500 in _run_push (docker sends to stdout)
|
|
||||||
|
|
||||||
## [0.51.4] - 2026-08-26
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Serialize registry uploads and retry on HTTP 500
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- Remove cross-repo contract tests from devx
|
|
||||||
|
|
||||||
## [0.51.3] - 2026-08-26
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fall back to CI bot token for auto-merge approval
|
|
||||||
|
|
||||||
## [0.51.2] - 2026-08-26
|
## [0.51.2] - 2026-08-26
|
||||||
|
|
||||||
### Bug Fixes
|
### 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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.51.12",
|
"devx>=0.51.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.51.12"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.51.2"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.51.12,<0.52"`).
|
> (for example, `"devx>=0.51.2,<0.52"`).
|
||||||
|
|
||||||
### Optional extras
|
### 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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.51.12",
|
"devx>=0.51.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.51.12"` or `"devx>=0.51.12,<0.52"`.
|
Pin a specific version if needed: `"devx==0.51.2"` or `"devx>=0.51.2,<0.52"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
# DEVX-161: Fix auto-merge self-approval: use CI bot token fallback
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
The auto-merge workflow posts an APPROVE review using
|
|
||||||
`REVIEWER_GITEA_API_TOKEN`. When this token belongs to the same user
|
|
||||||
who created the PR, Gitea rejects the self-approval, causing the merge
|
|
||||||
to fail with HTTP 405 "Does not have enough approvals."
|
|
||||||
|
|
||||||
## Approach
|
|
||||||
Try `REVIEWER_GITEA_API_TOKEN` first; if it fails (self-approval
|
|
||||||
rejection), fall back to `CI_GITEA_API_TOKEN` (kireto — CI bot account).
|
|
||||||
|
|
||||||
REQ-1: Auto-merge posts approval with fallback to CI bot token
|
|
||||||
REQ-2: Approval step reports which token succeeded
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
- Create a PR and observe auto-merge succeeds
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
- Merge to master
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
- Revert the merge commit
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [x] REQ-1: Auto-merge posts approval with fallback to CI bot token
|
|
||||||
- [x] REQ-2: Approval step reports which token succeeded
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# DEVX-162: Fix Gitea repo-variable read contract and fail closed on unknown nightly status
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`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
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
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-3: Tests cover the `data` field, the `value` fallback, and fail-closed
|
|
||||||
unknown status.
|
|
||||||
|
|
||||||
## Test Plan
|
|
||||||
|
|
||||||
- `pytest tests/unit/test_api_clients.py tests/unit/test_nightly_gate.py`
|
|
||||||
- `make lint-all` (ruff, pyright, bandit, translations)
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
|
|
||||||
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 commit; infra's pinned devx version keeps the previous behavior
|
|
||||||
until the dependency PR lands.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [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.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# DEVX-163: Add vikunja-tasks skill and skill validation tests, fix create-task docs
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
- `pytest tests/unit/test_skills_validation.py` passes (10 tests).
|
|
||||||
|
|
||||||
## Deploy Plan
|
|
||||||
|
|
||||||
Documentation/skills only — auto-merge to master; no runtime deploy.
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
Revert the squash-merge commit; skills are inert documentation.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [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).
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# DEVX-164: auto-merge resilience — self-approval and Vikunja outage handling
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
- 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 → 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; previous behavior returns.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [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
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.51.12",
|
"devx>=0.51.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.51.12",
|
"devx>=0.51.2",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
|
|||||||
molecule testing helpers for Ansible projects.
|
molecule testing helpers for Ansible projects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.51.12"
|
__version__ = "0.51.2"
|
||||||
|
|||||||
@@ -392,10 +392,7 @@ class GiteaClient:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
r = self._request("GET", f"/actions/variables/{name}")
|
r = self._request("GET", f"/actions/variables/{name}")
|
||||||
body = r.json()
|
return r.json().get("value")
|
||||||
if "data" in body:
|
|
||||||
return body["data"]
|
|
||||||
return body.get("value")
|
|
||||||
except APIError as e:
|
except APIError as e:
|
||||||
if e.status == 404:
|
if e.status == 404:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -21,12 +21,10 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient, VikunjaClient
|
from devx.api_clients import GiteaClient, VikunjaClient
|
||||||
@@ -112,27 +110,6 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_VIKUNJA_LOOKUP_ATTEMPTS = 4
|
|
||||||
_VIKUNJA_LOOKUP_BACKOFF = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
def _list_project_tasks(client: VikunjaClient, page: int) -> list[dict[str, Any]]:
|
|
||||||
"""List Vikunja tasks with bounded retries for transient outages.
|
|
||||||
|
|
||||||
Implements REQ-1: during a Vikunja restart the tasks endpoint can briefly
|
|
||||||
return 404/502; retry a few times so title validation rides out the window
|
|
||||||
instead of stranding an otherwise-valid PR.
|
|
||||||
"""
|
|
||||||
for attempt in range(1, _VIKUNJA_LOOKUP_ATTEMPTS + 1):
|
|
||||||
try:
|
|
||||||
return list(client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE))
|
|
||||||
except (APIError, requests.RequestException):
|
|
||||||
if attempt == _VIKUNJA_LOOKUP_ATTEMPTS:
|
|
||||||
raise
|
|
||||||
time.sleep(_VIKUNJA_LOOKUP_BACKOFF * attempt)
|
|
||||||
raise AssertionError("unreachable") # pragma: no cover
|
|
||||||
|
|
||||||
|
|
||||||
def get_vikunja_task_title(task_id: str) -> str:
|
def get_vikunja_task_title(task_id: str) -> str:
|
||||||
"""Fetch the Vikunja task title for the given DEVX-N identifier.
|
"""Fetch the Vikunja task title for the given DEVX-N identifier.
|
||||||
|
|
||||||
@@ -147,7 +124,7 @@ def get_vikunja_task_title(task_id: str) -> str:
|
|||||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||||
page = 1
|
page = 1
|
||||||
while True:
|
while True:
|
||||||
tasks = _list_project_tasks(client, page)
|
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||||
if not tasks:
|
if not tasks:
|
||||||
break
|
break
|
||||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||||
@@ -295,28 +272,14 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
|||||||
# Exit cleanly — the rebase triggers a new CI run that will retry.
|
# Exit cleanly — the rebase triggers a new CI run that will retry.
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
hint = ""
|
raise click.ClickException(
|
||||||
if e.status == 405:
|
_(
|
||||||
# Implements: REQ-2 — surface approval state. When the PR author
|
"Merge failed with HTTP {status}: {message}\n"
|
||||||
# and the CI reviewer token map to the same Gitea user,
|
"Please check the PR is ready and you have merge rights.",
|
||||||
# self-approval is rejected and the merge fails 405.
|
status=e.status,
|
||||||
try:
|
message=e.message,
|
||||||
reviews = client.get_pr_reviews(pr_num)
|
)
|
||||||
if not any(r.get("state") == "APPROVED" for r in reviews):
|
) from None
|
||||||
hint = _(
|
|
||||||
"\nNo APPROVED review found on the PR. If the PR author and the"
|
|
||||||
" CI reviewer token map to the same Gitea user, self-approval is"
|
|
||||||
" rejected — approve the PR via a non-author account, then"
|
|
||||||
" re-run the auto-merge job."
|
|
||||||
)
|
|
||||||
except APIError:
|
|
||||||
pass
|
|
||||||
msg = _(
|
|
||||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
|
||||||
status=e.status,
|
|
||||||
message=e.message,
|
|
||||||
)
|
|
||||||
raise click.ClickException(msg + hint) from None
|
|
||||||
|
|
||||||
click.echo(
|
click.echo(
|
||||||
_(
|
_(
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ DEFAULT_EXCLUDED_PATTERNS = [
|
|||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"README.md",
|
"README.md",
|
||||||
"docs/index.md",
|
"docs/index.md",
|
||||||
"docs/plans/*",
|
|
||||||
"*.svg",
|
"*.svg",
|
||||||
"uv.lock",
|
"uv.lock",
|
||||||
"poetry.lock",
|
"poetry.lock",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -129,23 +128,11 @@ def cli(
|
|||||||
owner, repo_name = repo.split("/", 1)
|
owner, repo_name = repo.split("/", 1)
|
||||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||||
|
|
||||||
# Implements: REQ-1 — this tool runs from the *producer* repo's CI, so
|
|
||||||
# every file lookup and git operation must happen inside a clone of the
|
|
||||||
# target repo, not the producer checkout in CWD.
|
|
||||||
workdir = Path(tempfile.mkdtemp(prefix="dep-pr-"))
|
|
||||||
clone_url = f"{GITEA_API_URL.removesuffix('/api/v1')}/{repo}.git"
|
|
||||||
auth_cfg = f"http.extraHeader=Authorization: token {token}"
|
|
||||||
subprocess.run( # nosec B603 B607
|
|
||||||
["git", "-c", auth_cfg, "clone", "--depth", "50", clone_url, str(workdir)],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find current pinned version
|
# Find current pinned version
|
||||||
old_version = None
|
old_version = None
|
||||||
changed_file = None
|
changed_file = None
|
||||||
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
|
for f in [PYPROJECT_PATH, IMAGES_YML_PATH, ROLE_DEFAULTS_PATH]:
|
||||||
old_version = find_pinned_version(package, str(workdir / f))
|
old_version = find_pinned_version(package, f)
|
||||||
if old_version:
|
if old_version:
|
||||||
changed_file = f
|
changed_file = f
|
||||||
break
|
break
|
||||||
@@ -197,21 +184,17 @@ def cli(
|
|||||||
else:
|
else:
|
||||||
raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None
|
raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None
|
||||||
|
|
||||||
# Check out the API-created branch inside the target clone.
|
# Clone, update file, commit, push
|
||||||
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True, cwd=workdir) # nosec B603 B607
|
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, cwd=workdir) # nosec B603 B607
|
subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True) # nosec B603 B607
|
||||||
|
|
||||||
if not changed_file or not update_pinned_version(str(workdir / changed_file), package, old_version, new_version):
|
if not changed_file or not update_pinned_version(changed_file, package, old_version, new_version):
|
||||||
raise click.ClickException(_("Failed to update {file}", file=changed_file))
|
raise click.ClickException(_("Failed to update {file}", file=changed_file))
|
||||||
|
|
||||||
subprocess.run(["git", "add", changed_file], check=True, cwd=workdir) # nosec B603 B607
|
subprocess.run(["git", "add", changed_file], check=True) # nosec B603 B607
|
||||||
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
|
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
|
||||||
subprocess.run(["git", "commit", "-m", commit_msg], check=True, cwd=workdir) # nosec B603 B607
|
subprocess.run(["git", "commit", "-m", commit_msg], check=True) # nosec B603 B607
|
||||||
subprocess.run( # nosec B603 B607
|
subprocess.run(["git", "push", "origin", branch_name], check=True) # nosec B603 B607
|
||||||
["git", "-c", auth_cfg, "push", "origin", branch_name],
|
|
||||||
check=True,
|
|
||||||
cwd=workdir,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Vikunja task for tracking
|
# Create Vikunja task for tracking
|
||||||
task_title = f"Bump {package} to {new_version}"
|
task_title = f"Bump {package} to {new_version}"
|
||||||
|
|||||||
@@ -97,17 +97,10 @@ def cli(repo: str, action: str, run_id: str, github_output: bool) -> None:
|
|||||||
write_github_output("nightly-status", status)
|
write_github_output("nightly-status", status)
|
||||||
raise click.ClickException(_("Nightly gate failed — staging deploy blocked."))
|
raise click.ClickException(_("Nightly gate failed — staging deploy blocked."))
|
||||||
else:
|
else:
|
||||||
click.echo(
|
click.echo(f"[nightly-gate] Unknown nightly status: {status} — allowing deploy.")
|
||||||
_(
|
|
||||||
"[nightly-gate] Unknown nightly status: {status} — blocking deploy (fail closed).",
|
|
||||||
status=status,
|
|
||||||
),
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
if github_output:
|
if github_output:
|
||||||
write_github_output("nightly-gate-passed", "false")
|
write_github_output("nightly-gate-passed", "true")
|
||||||
write_github_output("nightly-status", status)
|
write_github_output("nightly-status", status)
|
||||||
raise click.ClickException(_("Unknown nightly status — staging deploy blocked."))
|
|
||||||
|
|
||||||
elif action == "set-passed":
|
elif action == "set-passed":
|
||||||
set_nightly_status(client, f"passed:{run_id}" if run_id else "passed")
|
set_nightly_status(client, f"passed:{run_id}" if run_id else "passed")
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
|
|||||||
" Expected: <type>: <description>\n"
|
" Expected: <type>: <description>\n"
|
||||||
" Got: {subject}\n"
|
" Got: {subject}\n"
|
||||||
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
|
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
|
||||||
" perf, test, ci, build, deps, revert, BREAKING CHANGE",
|
" perf, test, ci, build, revert, BREAKING CHANGE",
|
||||||
subject=subject,
|
subject=subject,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -93,4 +93,4 @@ RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
|
|||||||
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||||
|
|
||||||
# Conventional commit regex — used by validate_commit_msg.py
|
# 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|deps)(\(.+\))?: .+")
|
CONVENTIONAL_RE = re.compile(r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert)(\(.+\))?: .+")
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
||||||
|
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
from devx.tokens import get_developer_token
|
from devx.tokens import get_developer_token
|
||||||
@@ -255,36 +254,6 @@ def delete_remote_manifest(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class PushHTTP500Error(Exception):
|
|
||||||
"""Raised when docker push fails with an HTTP 500 from the registry."""
|
|
||||||
|
|
||||||
|
|
||||||
def _run_push(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a docker push command, raising PushHTTP500Error on registry 500.
|
|
||||||
|
|
||||||
The Gitea container registry (v1.27.x) has a race condition in
|
|
||||||
BlobUploader.Append that causes intermittent HTTP 500 "offset
|
|
||||||
mismatch" errors during concurrent blob uploads. Retrying the
|
|
||||||
push gives the registry time to recover.
|
|
||||||
|
|
||||||
Docker sends push progress/errors to both stdout and stderr depending
|
|
||||||
on the error type, so both streams are checked for the 500 status.
|
|
||||||
Uses stderr=STDOUT to merge both streams into stdout, ensuring all
|
|
||||||
output is captured in one place (docker push output behavior varies
|
|
||||||
depending on TTY detection).
|
|
||||||
"""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 and "500" in (result.stdout or ""):
|
|
||||||
raise PushHTTP500Error(result.stdout.strip())
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def push_image(
|
def push_image(
|
||||||
spec: ImageSpec,
|
spec: ImageSpec,
|
||||||
registry: str,
|
registry: str,
|
||||||
@@ -301,9 +270,6 @@ def push_image(
|
|||||||
with Gitea #31964 ("package version already exists") do we delete
|
with Gitea #31964 ("package version already exists") do we delete
|
||||||
the old manifest and retry. This avoids losing the existing tag
|
the old manifest and retry. This avoids losing the existing tag
|
||||||
when the push fails for unrelated reasons (e.g. HTTP 500).
|
when the push fails for unrelated reasons (e.g. HTTP 500).
|
||||||
|
|
||||||
HTTP 500 errors from the Gitea registry race condition are retried
|
|
||||||
up to 3 times with exponential backoff (5s, 10s) via tenacity.
|
|
||||||
"""
|
"""
|
||||||
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
||||||
all_ok = True
|
all_ok = True
|
||||||
@@ -313,41 +279,19 @@ def push_image(
|
|||||||
click.echo(f"[dry-run] {' '.join(cmd)}")
|
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||||
continue
|
continue
|
||||||
click.echo(f"Pushing {ft}...")
|
click.echo(f"Pushing {ft}...")
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
@retry(
|
cmd,
|
||||||
stop=stop_after_attempt(5),
|
capture_output=True,
|
||||||
wait=wait_exponential(multiplier=10, min=10, max=60),
|
text=True,
|
||||||
retry=retry_if_exception_type(PushHTTP500Error),
|
check=False,
|
||||||
before_sleep=lambda retry_state: click.echo(
|
|
||||||
_(
|
|
||||||
" HTTP 500 from registry, retrying in {wait:.0f}s (attempt {attempt}/5)...",
|
|
||||||
wait=retry_state.next_action.sleep if retry_state.next_action else 0,
|
|
||||||
attempt=retry_state.attempt_number + 1,
|
|
||||||
),
|
|
||||||
err=True,
|
|
||||||
),
|
|
||||||
reraise=True,
|
|
||||||
)
|
)
|
||||||
def _attempt(_cmd: list[str] = cmd) -> subprocess.CompletedProcess[str]:
|
|
||||||
return _run_push(_cmd)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = _attempt()
|
|
||||||
except PushHTTP500Error as e:
|
|
||||||
click.echo(
|
|
||||||
_("Push failed for {tag}: {error}", tag=ft, error=str(e)),
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
all_ok = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
click.echo(f"Pushed {ft}")
|
click.echo(f"Pushed {ft}")
|
||||||
continue
|
continue
|
||||||
combined_output = (result.stdout or "").strip()
|
stderr = result.stderr.strip()
|
||||||
# Gitea #31964: push fails because tag already exists.
|
# Gitea #31964: push fails because tag already exists.
|
||||||
# Delete the old manifest and retry once.
|
# Delete the old manifest and retry once.
|
||||||
if username and token and "already exists" in combined_output.lower():
|
if username and token and "already exists" in stderr.lower():
|
||||||
click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...")
|
click.echo(" Tag exists (Gitea #31964), deleting old manifest and retrying...")
|
||||||
delete_remote_manifest(
|
delete_remote_manifest(
|
||||||
registry,
|
registry,
|
||||||
@@ -360,17 +304,16 @@ def push_image(
|
|||||||
click.echo(f" Retrying push {ft}...")
|
click.echo(f" Retrying push {ft}...")
|
||||||
result = subprocess.run( # nosec B603
|
result = subprocess.run( # nosec B603
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
capture_output=True,
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
click.echo(f"Pushed {ft} (after retry)")
|
click.echo(f"Pushed {ft} (after retry)")
|
||||||
continue
|
continue
|
||||||
combined_output = (result.stdout or "").strip()
|
stderr = result.stderr.strip()
|
||||||
click.echo(
|
click.echo(
|
||||||
_("Push failed for {tag}: {error}", tag=ft, error=combined_output),
|
_("Push failed for {tag}: {error}", tag=ft, error=stderr),
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
all_ok = False
|
all_ok = False
|
||||||
|
|||||||
+2844
-1850
File diff suppressed because it is too large
Load Diff
@@ -923,12 +923,6 @@ class TestGiteaClientActions:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_get_repo_variable_returns_value(self) -> None:
|
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 = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(return_value=_mock_response({"value": "v0.28.1"}))
|
client._session.request = MagicMock(return_value=_mock_response({"value": "v0.28.1"}))
|
||||||
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
|
result = client.get_repo_variable("PRODUCTION_DEPLOY_TAG")
|
||||||
|
|||||||
@@ -468,138 +468,3 @@ def test_main_module_block() -> None:
|
|||||||
exec(compile(source, am.__file__, "exec"), namespace)
|
exec(compile(source, am.__file__, "exec"), namespace)
|
||||||
# Verify main is callable
|
# Verify main is callable
|
||||||
assert callable(namespace["main"])
|
assert callable(namespace["main"])
|
||||||
|
|
||||||
|
|
||||||
# -- DEVX-164: Vikunja outage resilience + self-approval diagnostics --
|
|
||||||
|
|
||||||
|
|
||||||
class TestVikunjaLookupRetry:
|
|
||||||
"""REQ-1: transient Vikunja API failures are retried, not fatal."""
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.time.sleep")
|
|
||||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_retries_transient_api_error_then_succeeds(self, mock_client_cls: MagicMock, mock_sleep: MagicMock) -> None:
|
|
||||||
"""A 404/502 during a Vikunja restart is retried until tasks list."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.side_effect = [
|
|
||||||
APIError(404, "Not Found"),
|
|
||||||
APIError(502, "Bad Gateway"),
|
|
||||||
[{"id": 1, "identifier": "DEVX-19", "title": "Add new feature"}],
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
validate_pr_title_matches_vikunja("DEVX-19: Add new feature", "DEVX-19")
|
|
||||||
assert mock_client.list_project_tasks.call_count == 3
|
|
||||||
assert mock_sleep.call_count == 2
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.time.sleep")
|
|
||||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_retry_exhaustion_propagates_error(self, mock_client_cls: MagicMock, _mock_sleep: MagicMock) -> None:
|
|
||||||
"""Persistent outage still fails after the bounded attempt count."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.side_effect = APIError(502, "Bad Gateway")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with pytest.raises(APIError, match="Bad Gateway"):
|
|
||||||
validate_pr_title_matches_vikunja("DEVX-19: test", "DEVX-19")
|
|
||||||
assert mock_client.list_project_tasks.call_count == 4
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.time.sleep")
|
|
||||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_retries_connection_error(self, mock_client_cls: MagicMock, mock_sleep: MagicMock) -> None:
|
|
||||||
"""Connection-level failures during restart are also retried."""
|
|
||||||
import requests as req
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.side_effect = [
|
|
||||||
req.ConnectionError("refused"),
|
|
||||||
[{"id": 1, "identifier": "DEVX-19", "title": "Found me"}],
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
validate_pr_title_matches_vikunja("DEVX-19: Found me", "DEVX-19")
|
|
||||||
assert mock_sleep.call_count == 1
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_missing_task_still_fails_without_retry_sleep(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""A healthy Vikunja that simply lacks the task fails as before."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = []
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
|
||||||
validate_pr_title_matches_vikunja("DEVX-99: test", "DEVX-99")
|
|
||||||
|
|
||||||
|
|
||||||
class TestMergeApprovalDiagnostics:
|
|
||||||
"""REQ-2: merge 405 reports approval state + self-approval remediation."""
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
|
||||||
def test_405_without_approvals_shows_self_approval_hint(
|
|
||||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
|
|
||||||
mock_client.get_pr_reviews.return_value = []
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Merge failed" in result.output
|
|
||||||
assert "APPROVED" in result.output
|
|
||||||
assert "non-author account" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
|
||||||
def test_405_with_approvals_omits_hint(
|
|
||||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
|
|
||||||
mock_client.get_pr_reviews.return_value = [{"state": "APPROVED", "user": {"login": "kireto"}}]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Merge failed" in result.output
|
|
||||||
assert "non-author account" not in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
|
||||||
def test_405_reviews_fetch_failure_still_raises(
|
|
||||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
|
||||||
"""If the reviews lookup itself fails, the merge error still surfaces."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client.merge_pr.side_effect = APIError(405, "Does not have enough approvals")
|
|
||||||
mock_client.get_pr_reviews.side_effect = APIError(403, "Forbidden")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Merge failed" in result.output
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from click.testing import CliRunner
|
|||||||
import devx.tools.build_image as build_image
|
import devx.tools.build_image as build_image
|
||||||
from devx.tools.build_image import (
|
from devx.tools.build_image import (
|
||||||
ImageSpec,
|
ImageSpec,
|
||||||
PushHTTP500Error,
|
|
||||||
build_full_tag,
|
build_full_tag,
|
||||||
delete_remote_manifest,
|
delete_remote_manifest,
|
||||||
load_manifest,
|
load_manifest,
|
||||||
@@ -198,7 +197,7 @@ class TestBuildImage:
|
|||||||
class TestPushImage:
|
class TestPushImage:
|
||||||
def test_success(self) -> None:
|
def test_success(self) -> None:
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
||||||
mock_result = MagicMock(returncode=0, stdout="")
|
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
assert push_image(spec, "git.example.com") is True
|
assert push_image(spec, "git.example.com") is True
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
@@ -206,8 +205,8 @@ class TestPushImage:
|
|||||||
def test_partial_failure(self) -> None:
|
def test_partial_failure(self) -> None:
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
||||||
results = [
|
results = [
|
||||||
MagicMock(returncode=0, stdout=""),
|
MagicMock(returncode=0, stderr="", stdout=""),
|
||||||
MagicMock(returncode=1, stdout="push failed"),
|
MagicMock(returncode=1, stderr="push failed", stdout=""),
|
||||||
]
|
]
|
||||||
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
|
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
|
||||||
assert push_image(spec, "git.example.com") is False
|
assert push_image(spec, "git.example.com") is False
|
||||||
@@ -221,7 +220,7 @@ class TestPushImage:
|
|||||||
def test_no_delete_on_success_with_creds(self) -> None:
|
def test_no_delete_on_success_with_creds(self) -> None:
|
||||||
"""Push-first: no delete needed when push succeeds."""
|
"""Push-first: no delete needed when push succeeds."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
mock_result = MagicMock(returncode=0, stdout="")
|
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
with (
|
with (
|
||||||
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
||||||
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
||||||
@@ -231,7 +230,7 @@ class TestPushImage:
|
|||||||
|
|
||||||
def test_no_delete_without_creds(self) -> None:
|
def test_no_delete_without_creds(self) -> None:
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
mock_result = MagicMock(returncode=0, stdout="")
|
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||||
with (
|
with (
|
||||||
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
||||||
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
||||||
@@ -243,8 +242,8 @@ class TestPushImage:
|
|||||||
"""Gitea #31964: push fails with 'already exists', delete + retry."""
|
"""Gitea #31964: push fails with 'already exists', delete + retry."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
results = [
|
results = [
|
||||||
MagicMock(returncode=1, stdout="package version already exists"),
|
MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
|
||||||
MagicMock(returncode=0, stdout=""),
|
MagicMock(returncode=0, stderr="", stdout=""),
|
||||||
]
|
]
|
||||||
with (
|
with (
|
||||||
patch("devx.tools.build_image.subprocess.run", side_effect=results),
|
patch("devx.tools.build_image.subprocess.run", side_effect=results),
|
||||||
@@ -261,9 +260,11 @@ class TestPushImage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_no_delete_on_non_already_exists_failure(self) -> None:
|
def test_no_delete_on_non_already_exists_failure(self) -> None:
|
||||||
"""Push fails for other reasons (non-500) — old manifest preserved."""
|
"""Push fails for other reasons (HTTP 500) — old manifest preserved."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
mock_result = MagicMock(returncode=1, stdout="denied: requested access to the resource is denied")
|
mock_result = MagicMock(
|
||||||
|
returncode=1, stderr="received unexpected HTTP status: 500 Internal Server Error", stdout=""
|
||||||
|
)
|
||||||
with (
|
with (
|
||||||
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
||||||
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
||||||
@@ -275,8 +276,8 @@ class TestPushImage:
|
|||||||
"""Gitea #31964 retry also fails — both pushes fail."""
|
"""Gitea #31964 retry also fails — both pushes fail."""
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||||
results = [
|
results = [
|
||||||
MagicMock(returncode=1, stdout="package version already exists"),
|
MagicMock(returncode=1, stderr="500 Internal Server Error: already exists", stdout=""),
|
||||||
MagicMock(returncode=1, stdout="push failed again"),
|
MagicMock(returncode=1, stderr="push failed again", stdout=""),
|
||||||
]
|
]
|
||||||
with (
|
with (
|
||||||
patch("devx.tools.build_image.subprocess.run", side_effect=results),
|
patch("devx.tools.build_image.subprocess.run", side_effect=results),
|
||||||
@@ -284,60 +285,6 @@ class TestPushImage:
|
|||||||
):
|
):
|
||||||
assert push_image(spec, "git.example.com", username="user", token="tok") is False
|
assert push_image(spec, "git.example.com", username="user", token="tok") is False
|
||||||
|
|
||||||
def test_http_500_retries_then_succeeds(self) -> None:
|
|
||||||
"""HTTP 500 from registry race condition — retry succeeds."""
|
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
|
||||||
results = [
|
|
||||||
MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error"),
|
|
||||||
MagicMock(returncode=0, stdout=""),
|
|
||||||
]
|
|
||||||
with (
|
|
||||||
patch("devx.tools.build_image.subprocess.run", side_effect=results),
|
|
||||||
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
assert push_image(spec, "git.example.com", username="user", token="tok") is True
|
|
||||||
mock_del.assert_not_called()
|
|
||||||
|
|
||||||
def test_http_500_retries_all_fail(self) -> None:
|
|
||||||
"""HTTP 500 retries exhausted — push fails, no delete attempted."""
|
|
||||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error")
|
|
||||||
with (
|
|
||||||
patch("devx.tools.build_image.subprocess.run", return_value=mock_result),
|
|
||||||
patch("devx.tools.build_image.delete_remote_manifest") as mock_del,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
assert push_image(spec, "git.example.com", username="user", token="tok") is False
|
|
||||||
mock_del.assert_not_called()
|
|
||||||
|
|
||||||
def test_run_push_raises_on_500(self) -> None:
|
|
||||||
"""_run_push raises PushHTTP500Error when stdout contains 500."""
|
|
||||||
from devx.tools.build_image import _run_push
|
|
||||||
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="received unexpected HTTP status: 500 Internal Server Error")
|
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
|
||||||
with pytest.raises(PushHTTP500Error, match="500"):
|
|
||||||
_run_push(["docker", "push", "img:latest"])
|
|
||||||
|
|
||||||
def test_run_push_no_raise_on_non_500(self) -> None:
|
|
||||||
"""_run_push returns result when stdout has no 500."""
|
|
||||||
from devx.tools.build_image import _run_push
|
|
||||||
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="denied: access denied")
|
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
|
||||||
result = _run_push(["docker", "push", "img:latest"])
|
|
||||||
assert result.returncode == 1
|
|
||||||
|
|
||||||
def test_run_push_no_raise_on_success(self) -> None:
|
|
||||||
"""_run_push returns result on success."""
|
|
||||||
from devx.tools.build_image import _run_push
|
|
||||||
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="")
|
|
||||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
|
||||||
result = _run_push(["docker", "push", "img:latest"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteRemoteManifest:
|
class TestDeleteRemoteManifest:
|
||||||
def test_dry_run(self) -> None:
|
def test_dry_run(self) -> None:
|
||||||
|
|||||||
@@ -26,12 +26,6 @@ class TestIsExcluded:
|
|||||||
def test_excludes_readme(self) -> None:
|
def test_excludes_readme(self) -> None:
|
||||||
assert is_excluded("README.md", ["README.md"])
|
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:
|
class TestCheckSize:
|
||||||
def test_under_limits_passes(self) -> None:
|
def test_under_limits_passes(self) -> None:
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class TestConfigConstants:
|
|||||||
def test_conventional_re(self) -> None:
|
def test_conventional_re(self) -> None:
|
||||||
assert CONVENTIONAL_RE.match("feat: add feature")
|
assert CONVENTIONAL_RE.match("feat: add feature")
|
||||||
assert CONVENTIONAL_RE.match("fix(scope): bug fix")
|
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("random message")
|
||||||
assert not CONVENTIONAL_RE.match("feat:")
|
assert not CONVENTIONAL_RE.match("feat:")
|
||||||
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
|
assert not CONVENTIONAL_RE.match("BREAKING CHANGE: something")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from pathlib import Path
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.ci.create_dependency_pr import (
|
from devx.ci.create_dependency_pr import (
|
||||||
@@ -16,13 +15,6 @@ from devx.ci.create_dependency_pr import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _mock_subprocess():
|
|
||||||
"""Mock subprocess so CLI tests never run a real git clone."""
|
|
||||||
with patch("devx.ci.create_dependency_pr.subprocess.run") as m:
|
|
||||||
yield m
|
|
||||||
|
|
||||||
|
|
||||||
class TestFindPinnedVersion:
|
class TestFindPinnedVersion:
|
||||||
def test_finds_pip_git_pin(self, tmp_path: Path) -> None:
|
def test_finds_pip_git_pin(self, tmp_path: Path) -> None:
|
||||||
content = "grm @ git+https://git.example.com/repo.git@v0.5.1"
|
content = "grm @ git+https://git.example.com/repo.git@v0.5.1"
|
||||||
|
|||||||
@@ -71,17 +71,6 @@ class TestCli:
|
|||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "blocked" in result.output.lower()
|
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.GiteaClient")
|
||||||
@patch("devx.ci.nightly_gate.get_ci_token")
|
@patch("devx.ci.nightly_gate.get_ci_token")
|
||||||
def test_set_passed(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None:
|
def test_set_passed(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None:
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -30,7 +30,6 @@ class TestHelpers:
|
|||||||
assert CONVENTIONAL_RE.match("test: add tests")
|
assert CONVENTIONAL_RE.match("test: add tests")
|
||||||
assert CONVENTIONAL_RE.match("ci: update workflow")
|
assert CONVENTIONAL_RE.match("ci: update workflow")
|
||||||
assert CONVENTIONAL_RE.match("build: update deps")
|
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")
|
assert CONVENTIONAL_RE.match("revert: undo change")
|
||||||
|
|
||||||
def test_conventional_re_allows_scope(self) -> None:
|
def test_conventional_re_allows_scope(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user