feat: replace pr_review with spec-driven CI gates and pr-review skill
CI / validate (pull_request) Failing after 41s
CI / auto-merge (pull_request) Skipped

Add validate_spec, check_pr_size, fast_molecule, nightly_gate, and
create_dependency_pr CI modules. Remove the monolithic pr_review module
and its tests. Replace pr_review CI steps with validate_spec + check_pr_size
+ curl-based APPROVE. Add spec-driven-development and pr-review skills.
Remove dead get_reviewer_token. Update translations and AGENTS.md.

Closes DEVX-155

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
emil
2026-08-24 19:56:08 +02:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent a06caa0e88
commit aefc22de57
26 changed files with 3376 additions and 2035 deletions
+9 -1
View File
@@ -12,7 +12,6 @@ Quick reference for devx tools when working on the devx repo itself.
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
| Rebase current branch | `make rebase` |
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
@@ -24,6 +23,15 @@ When the `ready-to-merge` label is added and all CI checks pass:
3. The rebase triggers a new CI run; the next auto-merge attempt merges
4. No manual rebase needed unless the API rebase fails
## Spec-Driven CI Gates (Pre-merge)
Every PR must pass these gates before merge:
| Gate | Module | What it checks |
|------|--------|----------------|
| Spec validation | `devx.ci.validate_spec` | Spec file exists at `docs/specs/<TASK-ID>.md`, has REQ-IDs, all ACs checked |
| PR size | `devx.ci.check_pr_size` | Max 500 lines / 10 files (excludes CHANGELOG, badges, locks) |
## Key Rules
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
+272
View File
@@ -0,0 +1,272 @@
# pr-review
Deep, critical PR review with auto-fix. This skill guides the agent
through a thorough review of a pull request, posting inline comments
for each issue found, auto-fixing them, resolving the discussion threads,
and marking the PR as ready-to-merge when no blocking issues remain.
## When to Invoke
Invoke this skill when asked to review a PR, or when a PR is open and
needs review before merge. Do NOT invoke automatically on every PR —
this is an on-demand deep review, not a CI gate.
## Prerequisites
- The PR must be open in a Gitea repo
- The agent needs Gitea MCP access (gitea server)
- The agent needs git push access to the PR's head branch
- The PR should have passed CI (validate job) before deep review
## Review Categories
Review every PR against these 8 categories. For each issue found, post
an inline comment on the specific line, then auto-fix it.
### 1. Functional Correctness
- Does the code actually do what the spec/PR title claims?
- Are edge cases handled? (empty input, null, boundary values, concurrent access)
- Are error paths tested? Not just happy path.
- Does the code handle all return values? (ignored errors, unchecked None)
- Are there off-by-one errors, wrong comparisons, inverted conditions?
- Do loops terminate correctly? (no infinite loops, correct break/continue)
- Are regex patterns correct? (anchored, escaped, non-greedy where needed)
- Are API responses validated before use? (status codes, response shape)
### 2. Completeness
- Are all requirements from the spec implemented? (check each REQ-ID)
- Are all acceptance criteria in the spec checked off?
- Are tests written for all new code paths?
- Are error messages user-facing (wrapped in `_()`)?
- Are new CLI commands documented in `docs/user/cli-commands.md`?
- Are new modules added to architecture docs?
- Are CHANGELOG entries added for user-facing changes?
- Are translations added for new user-facing strings?
### 3. Architecture
- Does the code follow the repo's layer separation? (no business logic in CLI, no direct subprocess in CLI)
- Are new dependencies justified? (no unnecessary new packages)
- Is configuration via env vars / config.py, not hardcoded?
- Are new modules placed in the correct directory? (ci/ vs tools/ vs molecule/)
- Does the code reuse existing utilities? (no reimplemented helpers)
- Are imports circular? (check import chains)
- Is the code testable? (injectable dependencies, no hidden global state)
- Does the code follow existing patterns in the codebase?
### 4. Reliability
- Are external API calls retried with backoff?
- Are timeouts set on all network operations?
- Are file operations atomic? (write to temp, rename)
- Are database operations transactional where needed?
- Are there race conditions? (check shared mutable state)
- Are resources cleaned up in all paths? (finally blocks, context managers)
- Can the code handle partial failures? (one service down, others up)
- Are idempotency guarantees maintained? (safe to retry)
### 5. Robustness
- Does the code fail gracefully? (meaningful error messages, not stack traces)
- Are unexpected inputs handled? (type checking, validation)
- Are there any crash-on-bad-input paths?
- Does the code degrade under load? (backpressure, queue limits)
- Are there resource leaks? (file handles, connections, memory)
- Does the code survive network partitions? (retry, circuit breaker)
- Are there any unhandled exceptions that could crash the process?
- Is logging sufficient to diagnose production issues?
### 6. Security
- Are there hardcoded secrets, tokens, or passwords?
- Is `shell=True` used with user input? (command injection)
- Is `eval()` or `exec()` used? (code injection)
- Are SQL queries parameterized? (no string concatenation)
- Are file paths validated? (no path traversal)
- Are user inputs sanitized before display? (XSS in web contexts)
- Are SSL/TLS verifications disabled without justification?
- Are secrets logged in error messages or debug output?
- Are permissions checked before privileged operations?
- Is sensitive data in memory longer than necessary?
### 7. Technical Excellence
- Are functions under 50 lines? (refactor if longer)
- Is cyclomatic complexity reasonable? (no deeply nested if/else chains)
- Are names meaningful? (no single-letter vars, no misleading names)
- Is dead code removed? (no commented-out blocks, no unused imports)
- Are comments explaining WHY, not WHAT?
- Is the code DRY? (no copy-pasted blocks that should be shared)
- Is the code SOLID? (single responsibility, open/closed)
- Are magic numbers extracted to named constants?
- Is the code formatted per the repo's linter config?
- Are type hints present on all function signatures?
### 8. Test Quality
- Do tests actually test the behavior? (not just that code runs)
- Are tests independent? (no shared mutable state, no order dependency)
- Are tests fast? (no real sleeps, no real network calls, mocked)
- Are edge cases tested? (empty, None, boundary, error paths)
- Are test names descriptive? (test_what_condition_expected_result)
- Are mocks set up correctly? (mocking the right object, not too broad)
- Is coverage 100% for new code? (every branch, every line)
- Are integration tests added for cross-module changes?
- Do tests clean up after themselves? (tmp_path, fixtures)
## Review Procedure
### Step 1: Gather Context
```
1. Read the PR spec (if exists): docs/specs/<TASK-ID>.md
2. Fetch PR details via Gitea MCP: pull_request_read (get_pr, list_pr_files)
3. Read the full diff: git diff origin/master...HEAD
4. Read the PR description and any existing review comments
5. Identify the repo's task prefix (OBL-INFRA, GRM, SSO, DEVX)
```
### Step 2: Review Each File
For each changed file in the PR:
1. Read the full file (not just the diff) to understand context
2. Go through all 8 review categories
3. For each issue found, note: file path, line number, category, severity, description, suggested fix
### Step 3: Post Inline Comments
For each issue found, post an inline review comment using the Gitea MCP:
```
mcp_call_tool: gitea / pull_request_review_write
method: create
owner: <owner>
repo: <repo>
pull_number: <PR number>
state: PENDING (accumulate comments before submitting)
body: "" (empty for now, summary added on submit)
comments: [
{
path: "<file path>",
new_line_num: <line number>,
body: "**[<category>] [<severity>]** <description>\n\n**Suggested fix:**\n```<lang>\n<fixed code>\n```"
}
]
```
Comment format:
```
**[Security] [error]** `shell=True` used with user input — command injection risk.
**Suggested fix:**
```python
subprocess.run(["git", "log", commit], check=True)
```
```
Severity levels:
- `error` — must fix before merge (security, correctness, crash)
- `warning` — should fix before merge (reliability, best practice)
- `info` — consider fixing (style, minor improvement)
### Step 4: Auto-Fix Issues
For each issue that can be safely auto-fixed:
1. Edit the file using the `edit` tool
2. Commit with message: `fix: address review comment — <short description>`
3. Push to the PR's head branch: `git push origin HEAD`
4. Wait for CI to re-run on the push
Auto-fix ALL issues unless:
- The fix requires an architectural decision (ask the user)
- The fix changes public API behavior (ask the user)
- The fix is ambiguous (multiple valid approaches, ask the user)
### Step 5: Resolve Discussion Threads
After auto-fixing an issue and CI passes:
1. Find the review comment thread for that issue
2. Post a reply: `Fixed in <commit-sha>. Closing this thread.`
3. Resolve the discussion (if Gitea supports it via API)
4. If resolving via API is not available, the reply comment serves as resolution
### Step 6: Submit Final Review
After all issues are addressed (fixed or discussed):
```
mcp_call_tool: gitea / pull_request_review_write
method: submit
owner: <owner>
repo: <repo>
pull_number: <PR number>
review_id: <from step 3 create>
state: COMMENT (or APPROVED if no blocking issues remain)
body: <summary — see below>
```
### Step 7: Post Summary
Post a brief summary as a PR comment (via `issue_write / add_comment`):
```
## Deep Review Summary
- **Files reviewed:** N
- **Issues found:** N (N auto-fixed, N require attention)
- **Categories:** security (N), correctness (N), architecture (N), ...
**Outcome:** ✅ Ready to merge — all issues addressed.
**OR**
**Outcome:** ⚠️ N blocking issue(s) remain — see inline comments.
```
Keep the summary to 5-10 bullet points. Do not paste the full review.
### Step 8: Mark PR Ready
If all issues are addressed and no blocking issues remain:
```
mcp_call_tool: gitea / issue_write
method: add_labels
owner: <owner>
repo: <repo>
issue_number: <PR number>
labels: [<label_id for "ready-to-merge">]
```
If blocking issues remain, do NOT add the label. Post a comment
explaining what needs to be resolved before the PR can merge.
## Gitea MCP Tools Reference
| Action | MCP tool | Method |
|--------|----------|--------|
| Get PR details | `pull_request_read` | `get_pr` |
| List PR files | `pull_request_read` | `list_pr_files` |
| Get PR diff | `pull_request_read` | `get_pr_diff` |
| Create review (pending) | `pull_request_review_write` | `create` (state: PENDING) |
| Submit review | `pull_request_review_write` | `submit` (state: APPROVED/COMMENT/REQUEST_CHANGES) |
| Post PR comment | `issue_write` | `add_comment` |
| Add label | `issue_write` | `add_labels` |
| List labels | `label_read` | `list_repo_labels` |
| Merge PR | `pull_request_write` | `merge` (do NOT use — auto-merge handles this) |
## Important Rules
- **Never merge the PR yourself.** Add the `ready-to-merge` label and let
the auto-merge workflow handle it. This ensures CI passes and the
commit message follows the `<PREFIX>-N: <conventional>` format.
- **Never approve your own PR.** If the agent created the PR, post
COMMENT state, not APPROVED.
- **Always push fixes to the PR branch**, not directly to master.
- **Wait for CI after each push** before resolving the discussion thread.
- **Post one review with all comments**, not multiple reviews.
- **The summary must be brief** — 5-10 bullet points max.
- **Severity matters**: only `error` severity blocks the `ready-to-merge` label.
@@ -0,0 +1,130 @@
# Spec-Driven Development
## Overview
Every change starts with a spec. No spec, no code. No code, no PR.
The spec is a markdown file at `docs/specs/<TASK-ID>.md` in the repo.
It contains structured requirements (REQ-IDs) and acceptance criteria
(AC checklist) that CI validates before merge.
## Workflow
1. **Create Vikunja task**`make create-task -- --title "Title" --description "..."`
2. **Write spec** — Create `docs/specs/<TASK-ID>.md` (see template below)
3. **Create branch**`git checkout -b <PREFIX>-N-short-description`
4. **Implement** — Write code with `# Implements: REQ-N` comments
5. **Check ACs** — Tick all acceptance criteria checkboxes in the spec
6. **Push and create PR**`make push-with-pr`
7. **CI validates** — Spec validation, PR size check, fast molecule, lint, tests
8. **Auto-merge** — Add `ready-to-merge` label after review
9. **Auto-deploy** — Post-merge deploys to staging (if nightly gate is green)
## Spec Template
```markdown
# <TASK-ID>: <Title>
## Problem
<What is broken or missing? Why does this change exist?>
## Approach
<How will you solve it? What are the key design decisions?>
REQ-1: <First requirement description>
REQ-2: <Second requirement description>
REQ-3: <Third requirement description>
## Test Plan
- <How will you verify each REQ is implemented correctly?>
- <Include unit tests, molecule scenarios, integration tests>
## Deploy Plan
- <How will this change be deployed?>
- <What order do components need to deploy in?>
- <Are there migrations or one-time operations?>
## Rollback Plan
- <How do you revert if something goes wrong?>
- <What data/state changes are irreversible?>
## Acceptance Criteria
- [ ] REQ-1: <criterion that proves REQ-1 is done>
- [ ] REQ-2: <criterion that proves REQ-2 is done>
- [ ] REQ-3: <criterion that proves REQ-3 is done>
```
## CI Validation
The `devx.ci.validate_spec` module checks:
1. **Spec file exists** at `docs/specs/<TASK-ID>.md` (TASK-ID from branch name)
2. **Required sections present**: Problem, Approach, Test Plan, Deploy Plan, Rollback Plan, Acceptance Criteria
3. **At least one REQ-ID** line (format: `REQ-N: <description>`)
4. **All AC checkboxes checked** (`- [x]`, not `- [ ]`)
If any check fails, CI blocks the PR before expensive jobs run.
## PR Size Limits
CI enforces max 500 lines / 10 files changed (excluding CHANGELOG.md,
README.md, badges, lock files). Oversized PRs are rejected. Split your
work into smaller PRs.
## Code-to-Spec Linking
Each function, task, or template that implements a requirement should
have a comment:
```python
# Implements: REQ-1
def install_sso_bridge():
...
```
```yaml
# Implements: REQ-2
- name: Clone infra repo
git:
...
```
## Fast Molecule (Pre-merge)
CI runs molecule only for **changed roles** (detected via git diff),
with converge + verify only, single platform. This gives quick feedback
(~5-10 min) without the full molecule suite.
## Full Molecule (Nightly)
The complete molecule suite (all scenarios, all platforms) runs nightly
at 02:00 CET on master. If it fails:
- A Gitea issue is created with the `feedback` label
- The `NIGHTLY_STATUS` repo variable is set to `failed:<run_id>`
- All staging deploys are blocked until nightly passes again
## Auto-Deploy on Merge
Every merged PR auto-deploys to staging (if nightly gate is green).
No manual trigger needed. The deploy runs the full pipeline:
provision → deploy-observability → deploy-customer → configure-oidc.
For grm/sso-bridge: post-merge publishes the package, then auto-creates
an infra PR to bump the pinned version. That infra PR auto-deploys when
merged.
## Key Commands
```bash
# Validate spec locally (before pushing)
python -m devx.ci.validate_spec --branch <PREFIX>-N-description
# Check PR size locally
python -m devx.ci.check_pr_size --base origin/master --head HEAD
# See which roles need fast molecule
python -m devx.ci.fast_molecule --base origin/master --head HEAD
# Check nightly gate status
python -m devx.ci.nightly_gate --repo oblachno/infra --action check
```
@@ -45,6 +45,13 @@ This runs `lint-all` + `pytest-cov`. The pre-push git hook only
validates the Vikunja task exists — it does NOT run tests. You must
run `make pre-push` manually.
### Spec-Driven Workflow
Every PR requires a spec file at `docs/specs/<TASK-ID>.md`. See the
`spec-driven-development` skill for the full workflow and template.
CI validates the spec (via `devx.ci.validate_spec`) and checks PR size
(via `devx.ci.check_pr_size`) before running expensive jobs.
## CI Failure Investigation
When investigating a CI failure:
+27 -14
View File
@@ -106,14 +106,27 @@ jobs:
--pr-title "$PR_TITLE" \
--repo "$REPOSITORY" \
--pr-number "$PR_NUMBER"
- name: Run automated PR review
- name: Validate spec file
if: github.event_name == 'pull_request'
env:
DEVX_TASK_PREFIX: DEVX
PYTHONPATH: ${{ env.PYTHONPATH }}
HEAD_REF: ${{ github.head_ref }}
run: |
. .venv/bin/activate 2>/dev/null || true
set -euo pipefail
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
python3 -m devx.ci.validate_spec \
--branch "$HEAD_REF" \
--github-output
- name: Check PR size
if: github.event_name == 'pull_request'
env:
PYTHONPATH: ${{ env.PYTHONPATH }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_pr_size \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output
# --- release-dry-run step (conditional) ---
- name: Release dry-run validation
if: steps.detect.outputs.user-facing-changed == 'true'
@@ -165,18 +178,18 @@ jobs:
- name: Post approval review
env:
REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (validate job)."
# Post APPROVE review via Gitea API to satisfy branch protection
curl -s -X POST \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
-H "Authorization: token ${REVIEWER_GITEA_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event":"APPROVED","body":"Auto-approved: all CI checks passed (validate job)."}' \
|| echo "::warning::Failed to post approval review (best-effort)."
- name: Squash merge with task ID
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
+30 -2
View File
@@ -83,7 +83,6 @@ src/devx/
│ ├── classify_changes.py # User-facing vs infrastructure change detection
│ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
│ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
@@ -158,8 +157,37 @@ src/devx/
- **PYTHONPATH: src** — Workflows set `PYTHONPATH: src` (NOT `.:src` since there are no scripts at repo root)
- **Config via env vars** — `DEVX_*` environment variables with `.env` file fallback
## Spec-Driven Development
Every change starts with a spec. No spec, no code.
**Workflow:**
1. Create Vikunja task → get `<PREFIX>-N` task ID
2. Write spec at `docs/specs/<TASK-ID>.md` (see template in `.devin/skills/spec-driven-development/SKILL.md`)
3. Create branch, implement with `# Implements: REQ-N` comments
4. Tick all acceptance criteria checkboxes in spec
5. Push and create PR — CI validates spec before expensive jobs
**CI gates (pre-merge):**
- `devx.ci.validate_spec` — checks spec exists, has required sections, REQ-IDs, all ACs checked
- `devx.ci.check_pr_size` — max 500 lines / 10 files (excludes CHANGELOG, badges, locks)
- `devx.ci.fast_molecule` — converge+verify only for changed roles, single platform
**Nightly (infra only):**
- Full molecule suite (all scenarios, all platforms) + staging deploy + integration tests
- On failure: sets `NIGHTLY_STATUS=failed`, blocks staging deploys
- Post-merge auto-deploy to staging checks this gate before deploying
**Post-merge:**
- Infra: auto-deploys to staging (if nightly gate is green)
- GRM/sso-bridge: auto-publishes package, auto-creates infra dependency PR to bump pinned version
**Skill:** `.devin/skills/spec-driven-development/SKILL.md` — full template and workflow details.
## PR Workflow (Mandatory)
Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings)
@@ -210,7 +238,7 @@ docs: update README
### 6. Review the PR
**Automated review (CI `validate` job):** Every PR triggers an automated
review via `python -m devx.ci.pr_review` as a step in the `validate` job.
review via the `pr-review` skill (agent-invoked, not a CI step).
This posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found):
+46
View File
@@ -0,0 +1,46 @@
# DEVX-155: Replace pr_review with spec-driven CI gates and pr-review skill
## Problem
The `devx.ci.pr_review` module was a monolithic automated PR review tool that
ran in CI and posted COMMENT/REQUEST_CHANGES reviews. It duplicated logic now
better handled by an agent-invoked skill, and it blocked the introduction of
spec-driven development gates (validate_spec, check_pr_size) that should run
before expensive CI jobs.
## Approach
Remove `pr_review` and replace it with lightweight, focused CI gates plus a
new `pr-review` skill for deep agent-invoked reviews.
REQ-1: Add `devx.ci.validate_spec` — validates spec file exists, has required sections, REQ-IDs, all ACs checked
REQ-2: Add `devx.ci.check_pr_size` — enforces max 500 lines / 10 files (excludes CHANGELOG, badges, locks)
REQ-3: Add `devx.ci.fast_molecule` — detects changed roles, outputs fast molecule commands (converge+verify, single platform)
REQ-4: Add `devx.ci.nightly_gate` — checks/sets NIGHTLY_STATUS repo variable to block staging deploys on nightly failure
REQ-5: Add `devx.ci.create_dependency_pr` — auto-creates infra PR to bump pinned package version after grm/sso-bridge release
REQ-6: Remove `devx.ci.pr_review` module and `tests/unit/test_pr_review.py`
REQ-7: Update CI workflows to replace pr_review steps with validate_spec + check_pr_size + curl-based APPROVE
REQ-8: Add `spec-driven-development` and `pr-review` skills under `.devin/skills/`
REQ-9: Update AGENTS.md and skill docs to document the new spec-driven workflow
## Test Plan
- Unit tests for each new module (test_validate_spec, test_check_pr_size, test_fast_molecule, test_nightly_gate, test_create_dependency_pr, test_spec_driven_workflows)
- Remove test_pr_review.py and pr_review references from test_cli.py (pr_review.py deleted from source)
- Verify CI workflow YAML passes actionlint
## Deploy Plan
- Merge to master via auto-merge workflow
- devx post-merge publishes new version; downstream repos (grm, infra, sso-bridge) bump their devx pin
## Rollback Plan
- Revert the merge commit; downstream repos keep their current devx pin
- pr_review.py can be restored from git history if needed
## Acceptance Criteria
- [x] REQ-1: `devx.ci.validate_spec` module exists with `--branch` and `--github-output` options
- [x] REQ-2: `devx.ci.check_pr_size` module exists with `--base`, `--head`, `--github-output` options
- [x] REQ-3: `devx.ci.fast_molecule` module exists and outputs changed roles + commands
- [x] REQ-4: `devx.ci.nightly_gate` module exists with `--action check/set-passed/set-failed`
- [x] REQ-5: `devx.ci.create_dependency_pr` module exists with `--repo`, `--package`, `--new-version` options
- [x] REQ-6: The pr_review CI module and its test file are deleted from source tree
- [x] REQ-7: CI workflow uses validate_spec + check_pr_size + curl APPROVE instead of pr_review
- [x] REQ-8: `.devin/skills/spec-driven-development/SKILL.md` and `.devin/skills/pr-review/SKILL.md` exist
- [x] REQ-9: AGENTS.md documents spec-driven development workflow and pr-review skill
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# Implements: REQ-2
"""Check PR size and reject oversized PRs.
Enforces max lines changed and max files changed to keep PRs small
and deployable. Generated/excluded files are not counted.
Usage:
python -m devx.ci.check_pr_size --base origin/master --head HEAD
In CI, pass ``--github-output`` to set ``pr-size-ok`` and ``pr-size-detail``
for downstream steps.
"""
from __future__ import annotations
import subprocess # nosec B404
import click
from dotenv import load_dotenv
from devx.ci._shared import write_github_output
from devx.i18n import _
load_dotenv()
# Files/patterns excluded from size counting (generated, badges, locks, etc.)
DEFAULT_EXCLUDED_PATTERNS = [
"CHANGELOG.md",
"README.md",
"docs/index.md",
"*.svg",
"uv.lock",
"poetry.lock",
"Pipfile.lock",
"package-lock.json",
"yarn.lock",
"go.sum",
]
DEFAULT_MAX_LINES = 500
DEFAULT_MAX_FILES = 10
def get_diff_stats(base: str, head: str) -> list[tuple[str, int, int]]:
"""Get per-file diff stats (additions, deletions) between base and head.
Returns a list of (filename, additions, deletions) tuples.
"""
result = subprocess.run( # nosec B603 B607
["git", "diff", "--numstat", base, head],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(_("git diff --numstat failed: {stderr}", stderr=result.stderr.strip()))
stats: list[tuple[str, int, int]] = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("\t")
if len(parts) != 3:
continue
additions_s, deletions_s, filename = parts
# Binary files show "-" for additions/deletions
additions = int(additions_s) if additions_s.isdigit() else 0
deletions = int(deletions_s) if deletions_s.isdigit() else 0
stats.append((filename, additions, deletions))
return stats
def is_excluded(filename: str, excluded_patterns: list[str]) -> bool:
"""Check if a filename matches any excluded pattern."""
from fnmatch import fnmatch
return any(fnmatch(filename, pat) for pat in excluded_patterns)
def check_size(
stats: list[tuple[str, int, int]],
max_lines: int,
max_files: int,
excluded_patterns: list[str],
) -> tuple[bool, str]:
"""Check diff stats against limits.
Returns (is_ok, detail_message).
"""
included = [(f, a, d) for f, a, d in stats if not is_excluded(f, excluded_patterns)]
total_lines = sum(a + d for _, a, d in included)
total_files = len(included)
if total_files == 0:
return True, "No non-excluded files changed"
if total_files > max_files:
return False, _(
"PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.",
file_count=total_files,
max_files=max_files,
excluded_count=len(stats) - total_files,
)
if total_lines > max_lines:
return False, _(
"PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.",
line_count=total_lines,
max_lines=max_lines,
excluded_count=len(stats) - total_files,
)
return True, _(
"PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).",
file_count=total_files,
line_count=total_lines,
max_files=max_files,
max_lines=max_lines,
)
@click.command()
@click.option("--base", default="origin/master", help=_("Base ref for diff"))
@click.option("--head", default="HEAD", help=_("Head ref for diff"))
@click.option(
"--max-lines",
type=int,
default=DEFAULT_MAX_LINES,
help=_("Max lines changed (excluded files not counted)"),
)
@click.option(
"--max-files",
type=int,
default=DEFAULT_MAX_FILES,
help=_("Max files changed (excluded files not counted)"),
)
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help=_("Write results to $GITHUB_OUTPUT"),
)
@click.option(
"--excluded",
"excluded",
multiple=True,
help=_("Additional excluded patterns (in addition to defaults)"),
)
def cli(
base: str,
head: str,
max_lines: int,
max_files: int,
github_output: bool,
excluded: tuple[str, ...],
) -> None:
"""Check PR size and reject oversized PRs."""
excluded_patterns = list(DEFAULT_EXCLUDED_PATTERNS) + list(excluded)
stats = get_diff_stats(base, head)
is_ok, detail = check_size(stats, max_lines, max_files, excluded_patterns)
if github_output:
write_github_output("pr-size-ok", "true" if is_ok else "false")
write_github_output("pr-size-detail", detail)
if is_ok:
click.echo(f"[pr-size] {detail}")
else:
click.echo(f"[pr-size] FAILED: {detail}", err=True)
click.echo("", err=True)
click.echo("Oversized PRs cannot be reliably reviewed or deployed independently.", err=True)
click.echo("Split your work into smaller PRs, each addressing one concern.", err=True)
raise click.ClickException(_("PR size check failed."))
if __name__ == "__main__": # pragma: no cover
cli()
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
# Implements: REQ-5
"""Auto-create an infra PR to bump a pinned dependency version.
After grm or sso-bridge publishes a new package version, this module
creates a PR in the infra repo to bump the pinned version in
``pyproject.toml`` or ``ansible/group_vars/all/images.yml``.
Reuses ``devx.tools.create_pr`` for PR creation and Vikunja task linking.
Usage:
python -m devx.ci.create_dependency_pr \
--repo oblachno/infra \
--package grm \
--new-version 0.5.2 \
--source-repo oblachno/grm \
--source-run-id 12345
"""
from __future__ import annotations
import re
import subprocess # nosec B404
from pathlib import Path
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_ci_token, get_vikunja_token
from devx.tools.create_pr import find_existing_pr
load_dotenv()
# Where infra pins dependency versions
PYPROJECT_PATH = "pyproject.toml"
IMAGES_YML_PATH = "ansible/group_vars/all/images.yml"
def find_pinned_version(package: str, file_path: str) -> str | None:
"""Find the currently pinned version of a package in a file.
Looks for patterns like:
- ``"grm @ git+...@v0.5.1"``
- ``grm = "0.5.1"``
- ``grm_version: "0.5.1"``
- ``grm_image_version: "0.5.1"``
"""
path = Path(file_path)
if not path.exists():
return None
content = path.read_text(encoding="utf-8")
# Match various pinning patterns
patterns = [
rf"{package}\s*@\s*git\+[^@]+@v?([\d.]+)", # pip: package @ git+url@vX.Y.Z
rf'{package}\s*=\s*"([\d.]+)"', # pyproject: package = "X.Y.Z"
rf'{package}_version:\s*"([\d.]+)"', # ansible vars: package_version: "X.Y.Z"
rf'{package}_image_version:\s*"([\d.]+)"', # ansible vars: package_image_version: "X.Y.Z"
]
for pat in patterns:
match = re.search(pat, content)
if match:
return match.group(1)
return None
def update_pinned_version(file_path: str, package: str, old_version: str, new_version: str) -> bool:
"""Update the pinned version in a file. Returns True if changed."""
path = Path(file_path)
if not path.exists():
return False
content = path.read_text(encoding="utf-8")
# Replace old version with new version in package-related lines
patterns = [
(rf"({package}\s*@\s*git\+[^@]+@v?){old_version}", rf"\g<1>{new_version}"),
(rf'({package}\s*=\s*"){old_version}(")', rf"\g<1>{new_version}\g<2>"),
(rf'({package}_version:\s*"){old_version}(")', rf"\g<1>{new_version}\g<2>"),
(rf'({package}_image_version:\s*"){old_version}(")', rf"\g<1>{new_version}\g<2>"),
]
new_content = content
changed = False
for pat, replacement in patterns:
new_content, n = re.subn(pat, replacement, new_content)
if n > 0:
changed = True
if changed:
path.write_text(new_content, encoding="utf-8")
return changed
def create_vikunja_task(title: str, description: str) -> str | None:
"""Create a Vikunja task and return its identifier (e.g., OBL-INFRA-531)."""
try:
token = get_vikunja_token()
except click.ClickException:
return None
from devx.api_clients import VikunjaClient
client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.create_task(VIKUNJA_PROJECT_ID, title=title, description=description)
return str(task.get("identifier", ""))
@click.command()
@click.option("--repo", default="oblachno/infra", help=_("Target repo (owner/name) to create PR in"))
@click.option("--package", required=True, help=_("Package name to bump (e.g., grm, sso-bridge)"))
@click.option("--new-version", required=True, help=_("New version to pin"))
@click.option("--source-repo", required=True, help=_("Source repo that published (owner/name)"))
@click.option("--source-run-id", default="", help=_("CI run ID that triggered the publish"))
@click.option("--dry-run", is_flag=True, default=False, help=_("Show what would be done without creating PR"))
def cli(
repo: str,
package: str,
new_version: str,
source_repo: str,
source_run_id: str,
dry_run: bool,
) -> None:
"""Create an infra PR to bump a pinned dependency version."""
token = get_ci_token()
if "/" not in repo:
raise click.ClickException(_("Invalid repo format: {repo}", repo=repo))
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
# Find current pinned version
old_version = None
changed_file = None
for f in [PYPROJECT_PATH, IMAGES_YML_PATH]:
old_version = find_pinned_version(package, f)
if old_version:
changed_file = f
break
if not old_version:
click.echo(_("[dep-pr] Could not find pinned version for {pkg} in infra repo.", pkg=package))
if dry_run:
return
raise click.ClickException(_("Could not find pinned version for {pkg}", pkg=package))
if old_version == new_version:
click.echo(_("[dep-pr] {pkg} already at {version} — no PR needed.", pkg=package, version=new_version))
return
click.echo(
_(
"[dep-pr] Bumping {pkg} from {old} to {new} in {file}",
pkg=package,
old=old_version,
new=new_version,
file=changed_file,
)
)
if dry_run:
click.echo(f"[dep-pr] DRY RUN: would update {changed_file} and create PR")
return
# Create a branch
branch_name = f"deps/{package}-{new_version}"
base_branch = "master"
# Check for existing PR (reuse from tools.create_pr)
existing = find_existing_pr(client, branch_name)
if existing:
click.echo(_("[dep-pr] PR already exists: #{number}", number=existing.get("number", "?")))
return
# Create branch via API
try:
master_ref = client._request("GET", "/git/refs/heads/master").json()
master_sha = master_ref.get("object", {}).get("sha", "")
if not master_sha:
raise click.ClickException("Could not get master SHA")
client._request("POST", "/git/refs", json={"ref": f"refs/heads/{branch_name}", "sha": master_sha})
except APIError as e:
if "already exists" in str(e).lower():
click.echo(f"[dep-pr] Branch {branch_name} already exists")
else:
raise click.ClickException(_("Failed to create branch: {error}", error=str(e))) from None
# Clone, update file, commit, push
subprocess.run(["git", "fetch", "origin", f"{branch_name}"], check=False, capture_output=True) # nosec B603 B607
subprocess.run(["git", "checkout", branch_name], check=False, capture_output=True) # nosec B603 B607
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))
subprocess.run(["git", "add", changed_file], check=True) # nosec B603 B607
commit_msg = f"deps: bump {package} from {old_version} to {new_version}"
subprocess.run(["git", "commit", "-m", commit_msg], check=True) # nosec B603 B607
subprocess.run(["git", "push", "origin", branch_name], check=True) # nosec B603 B607
# Create Vikunja task for tracking
task_title = f"Bump {package} to {new_version}"
task_desc = (
f"<p>Auto-created dependency bump PR.</p>"
f"<p>Package: {package}</p>"
f"<p>Version: {old_version}{new_version}</p>"
f"<p>Source: {source_repo} (run #{source_run_id})</p>"
)
task_id = create_vikunja_task(task_title, task_desc)
# Create PR directly (dependency PRs have custom titles, not Vikunja-derived)
pr_title = f"{task_id}: {task_title}" if task_id else task_title
pr_body = (
f"## Dependency Bump\n\n"
f"Bumps **{package}** from `{old_version}` to `{new_version}`.\n\n"
f"- **Source**: {source_repo}\n"
f"- **Triggered by**: CI run #{source_run_id}\n"
f"- **Changed file**: `{changed_file}`\n\n"
f"This PR was auto-created by `devx.ci.create_dependency_pr`.\n"
)
if task_id:
pr_body += f"\nCloses {task_id}"
pr = client.create_pr(title=pr_title, head=branch_name, base=base_branch, body=pr_body)
click.echo(_("[dep-pr] Created PR #{number}: {title}", number=pr.get("number", "?"), title=pr_title))
if __name__ == "__main__": # pragma: no cover
cli()
-1
View File
@@ -44,7 +44,6 @@ REQUIRED_SCRIPTS = [
"auto_merge.py",
"release.py",
"publish.py",
"pr_review.py",
"notify_failure.py",
"post_merge.py",
"classify_changes.py",
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# Implements: REQ-3
"""Detect changed Ansible roles and output fast molecule test commands.
Reuses ``devx.molecule.molecule_changed`` for role detection (which handles
playbook→role mapping and shared infrastructure paths).
Fast molecule = converge + verify only, single platform, no idempotence
check. Used in pre-merge CI to get quick feedback on Ansible changes
without running the full molecule suite (which runs nightly).
Usage:
python -m devx.ci.fast_molecule --base origin/master --head HEAD
Outputs the list of changed roles and the molecule commands to run.
In CI, pass ``--github-output`` to set ``fast-molecule-roles`` (space-
separated) and ``fast-molecule-needed`` (true/false) for downstream steps.
"""
from __future__ import annotations
from pathlib import Path
import click
from dotenv import load_dotenv
from devx.ci._shared import write_github_output
from devx.i18n import _
from devx.molecule.molecule_changed import detect_changed_roles, get_changed_files
load_dotenv()
def get_molecule_scenarios(role_name: str, roles_dir: str = "ansible/roles") -> list[str]:
"""Get list of molecule scenario names for a role."""
mol_dir = Path(roles_dir) / role_name / "molecule"
if not mol_dir.is_dir():
return []
scenarios = []
for p in mol_dir.iterdir():
if p.is_dir() and (p / "molecule.yml").exists():
scenarios.append(p.name)
return sorted(scenarios)
def build_molecule_commands(
roles: set[str],
roles_dir: str = "ansible/roles",
platform: str = "ubuntu-2604",
) -> list[str]:
"""Build molecule test commands for changed roles.
For each role, runs each scenario with converge + verify only
(skip create/destroy between scenarios, skip idempotence).
"""
commands: list[str] = []
for role in sorted(roles):
scenarios = get_molecule_scenarios(role, roles_dir)
if not scenarios:
continue
for scenario in scenarios:
cmd = f"molecule test -s {scenario} --destroy=never --platform-name={platform}"
commands.append(cmd)
return commands
@click.command()
@click.option("--base", default="origin/master", help=_("Base ref for diff"))
@click.option("--head", default="HEAD", help=_("Head ref for diff"))
@click.option("--roles-dir", default="ansible/roles", help=_("Directory containing Ansible roles"))
@click.option("--platform", default="ubuntu-2604", help=_("Single platform to test against"))
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help=_("Write results to $GITHUB_OUTPUT"),
)
def cli(
base: str,
head: str,
roles_dir: str,
platform: str,
github_output: bool,
) -> None:
"""Detect changed roles and output fast molecule test commands."""
# Use molecule_changed for role detection (handles playbooks, shared infra)
files = get_changed_files(base)
if not files:
click.echo("[fast-molecule] No files changed.")
if github_output:
write_github_output("fast-molecule-needed", "false")
write_github_output("fast-molecule-roles", "")
return
roles = detect_changed_roles(files)
if not roles:
click.echo("[fast-molecule] No Ansible roles changed.")
if github_output:
write_github_output("fast-molecule-needed", "false")
write_github_output("fast-molecule-roles", "")
return
commands = build_molecule_commands(roles, roles_dir, platform)
if github_output:
write_github_output("fast-molecule-needed", "true" if commands else "false")
write_github_output("fast-molecule-roles", " ".join(sorted(roles)))
click.echo(_("[fast-molecule] Changed roles: {roles}", roles=", ".join(sorted(roles))))
if not commands:
click.echo("[fast-molecule] No molecule scenarios found for changed roles.")
return
click.echo(f"[fast-molecule] {len(commands)} scenario(s) to run:")
for cmd in commands:
click.echo(f" {cmd}")
if __name__ == "__main__": # pragma: no cover
cli()
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
# Implements: REQ-4
"""Check if the nightly CI gate has passed; block staging deploys if it failed.
The nightly gate stores its status as a Gitea Actions repository variable
named ``NIGHTLY_STATUS`` on the infra repo. Values:
- ``passed`` — nightly molecule + staging deploy + integration tests passed.
- ``failed:<run_id>`` — nightly failed. Staging deploys are blocked until
the nightly passes again.
- (not set) — nightly hasn't run yet. First deploy is allowed (bootstrap).
Usage:
python -m devx.ci.nightly_gate --repo oblachno/infra --action check
python -m devx.ci.nightly_gate --repo oblachno/infra --action set-passed --run-id 12345
python -m devx.ci.nightly_gate --repo oblachno/infra --action set-failed --run-id 12345
"""
from __future__ import annotations
import click
from dotenv import load_dotenv
from devx.api_clients import GiteaClient
from devx.ci._shared import write_github_output
from devx.config import GITEA_API_URL
from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv()
NIGHTLY_STATUS_VAR = "NIGHTLY_STATUS"
def get_nightly_status(client: GiteaClient) -> str:
"""Get the nightly status variable. Returns empty string if not set."""
val = client.get_repo_variable(NIGHTLY_STATUS_VAR)
return val or ""
def set_nightly_status(client: GiteaClient, status: str) -> None:
"""Set the nightly status variable."""
client.set_repo_variable(NIGHTLY_STATUS_VAR, status)
@click.command()
@click.option("--repo", required=True, help=_("Repository in owner/name format"))
@click.option(
"--action",
type=click.Choice(["check", "set-passed", "set-failed"]),
required=True,
help=_("Action to perform"),
)
@click.option("--run-id", default="", help=_("CI run ID (for set-failed/set-passed)"))
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help=_("Write results to $GITHUB_OUTPUT"),
)
def cli(repo: str, action: str, run_id: str, github_output: bool) -> None:
"""Check or set the nightly CI gate status."""
token = get_ci_token()
if "/" not in repo:
raise click.ClickException(_("Invalid repo format: {repo}. Expected owner/name.", repo=repo))
owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if action == "check":
status = get_nightly_status(client)
if not status:
# Bootstrap: no nightly has run yet, allow deploy
click.echo("[nightly-gate] No nightly status set — allowing deploy (bootstrap).")
if github_output:
write_github_output("nightly-gate-passed", "true")
write_github_output("nightly-status", "")
return
if status.startswith("passed"):
click.echo("[nightly-gate] Nightly passed. Deploy allowed.")
if github_output:
write_github_output("nightly-gate-passed", "true")
write_github_output("nightly-status", status)
elif status.startswith("failed"):
run_part = status.split(":", 1)[1] if ":" in status else ""
run_link = f" (run #{run_part})" if run_part else ""
click.echo(
_(
"[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.",
run=run_link,
),
err=True,
)
if github_output:
write_github_output("nightly-gate-passed", "false")
write_github_output("nightly-status", status)
raise click.ClickException(_("Nightly gate failed — staging deploy blocked."))
else:
click.echo(f"[nightly-gate] Unknown nightly status: {status} — allowing deploy.")
if github_output:
write_github_output("nightly-gate-passed", "true")
write_github_output("nightly-status", status)
elif action == "set-passed":
set_nightly_status(client, f"passed:{run_id}" if run_id else "passed")
click.echo(_("[nightly-gate] Set NIGHTLY_STATUS=passed{run}", run=f":{run_id}" if run_id else ""))
if github_output:
write_github_output("nightly-status", f"passed:{run_id}" if run_id else "passed")
elif action == "set-failed":
set_nightly_status(client, f"failed:{run_id}" if run_id else "failed")
click.echo(_("[nightly-gate] Set NIGHTLY_STATUS=failed{run}", run=f":{run_id}" if run_id else ""))
if github_output:
write_github_output("nightly-status", f"failed:{run_id}" if run_id else "failed")
if __name__ == "__main__": # pragma: no cover
cli()
-715
View File
@@ -1,715 +0,0 @@
#!/usr/bin/env python3
"""Automated PR review: check architecture compliance, best practices, and quality.
Fetches the PR diff via the Gitea API, runs a series of automated checks,
and posts a structured review using GiteaClient.create_review.
Checks performed:
1. Architecture compliance — no business logic in CLI, no direct subprocess
calls outside executor, no hardcoded config that should be in config.py
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
left in merged code, no functions > 50 lines
3. Security — no secrets in code, no shell=True, no eval/exec
4. i18n — no raw English strings in click.echo() without _() wrapper
5. Resource management — no open() without with statement, no subprocess without cleanup
6. Documentation — new CLI commands documented, new modules in architecture.md
7. Test coverage — 100% enforced by pytest-cov (checked in quality job)
8. Commit conventions — conventional commit format on branch commits
Usage:
CI_GITEA_API_TOKEN=<token> [REVIEWER_GITEA_API_TOKEN=<token>] python3 -m devx.ci.pr_review <pr_number> <owner/repo>
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL
from devx.exceptions import APIError
from devx.i18n import _
from devx.tokens import get_ci_token, get_reviewer_token
load_dotenv()
# Files that are exempt from certain checks
WORKFLOW_ONLY_SUFFIXES = (".yml", ".yaml", ".md", ".json", ".toml", ".cfg", ".ini", ".txt")
PYTHON_SUFFIX = ".py"
# Architecture rules
CLI_FILE = "src/devx/cli.py"
EXECUTOR_FILE = "src/devx/executor.py"
CONFIG_FILE = "src/devx/config.py"
# Patterns that indicate business logic in CLI (should be in runner_manager.py)
BUSINESS_LOGIC_IN_CLI = [
(r"subprocess\.(run|call|Popen|check_output|check_call)", "subprocess call in CLI — delegate to executor.py"),
(r"\bos\.system\b", "os.system call in CLI — delegate to executor.py"),
(r"\bansible-playbook\b", "ansible-playbook reference in CLI — delegate to executor.py"),
]
# Patterns that indicate bad practices
BAD_PRACTICES = [
(r"\bprint\s*\(", "print() found — use click.echo() for user output"),
(r"\beval\s*\(", "eval() found — security risk, avoid dynamic code execution"),
(r"\bexec\s*\(", "exec() found — security risk, avoid dynamic code execution"),
(r"shell\s*=\s*True", "shell=True found — security risk, use shell=False with list args"),
(r"except\s*:", "bare except found — catch specific exceptions"),
(r"except\s+Exception\s*:", "broad Exception catch — catch specific exceptions"),
(r"#\s*(TODO|FIXME|HACK|XXX)", "TODO/FIXME found — resolve before merging"),
]
# Patterns for hardcoded config values that should be in config.py
HARDCODED_CONFIG = [
(r"https?://[a-z]+\.[a-z]+\.[a-z]+", "hardcoded URL — move to config.py with env var override"),
]
@dataclass
class ReviewResult:
"""Result of automated review checks."""
issues: list[dict[str, Any]] = field(default_factory=list)
summary: list[str] = field(default_factory=list)
@property
def has_issues(self) -> bool:
return bool(self.issues)
def add_issue(self, file_path: str, line: int, message: str, severity: str = "warning") -> None:
self.issues.append(
{
"path": file_path,
"body": f"[{severity}] {message}",
"new_position": line,
}
)
def add_summary(self, text: str) -> None:
self.summary.append(text)
def is_python_file(path: str) -> bool:
"""Check if a file is a Python source file."""
return path.endswith(PYTHON_SUFFIX) and not path.startswith("tests/")
def is_workflow_only(path: str) -> bool:
"""Check if a file is workflow/config/docs only (not Python source)."""
return path.endswith(WORKFLOW_ONLY_SUFFIXES) or path.startswith((".gitea/", "docs/", "ansible/"))
def check_architecture_compliance(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that changes follow the documented architecture."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Check for business logic in CLI
if path == CLI_FILE:
for pattern, msg in BUSINESS_LOGIC_IN_CLI:
if re.search(pattern, content):
result.add_issue(path, current_line, msg, "error")
if not result.issues:
result.add_summary("- Architecture compliance: OK")
def check_best_practices(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for common code quality issues."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
for pattern, msg in BAD_PRACTICES:
if re.search(pattern, content):
result.add_issue(path, current_line, msg, "warning")
if not any(i["body"].startswith("[warning]") for i in result.issues):
result.add_summary("- Best practices: OK")
def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for security issues in changed files."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Check for hardcoded secrets
secret_re = r'(token|password|secret|key)\s*=\s*["\'][^"\']{8,}["\']' # nosec B105
is_secret = re.search(secret_re, content, re.IGNORECASE)
is_comment = content.strip().startswith("#")
is_example = "your-" in content or "example" in content
if is_secret and not is_comment and not is_example:
result.add_issue(
path,
current_line,
"potential hardcoded secret — use environment variable",
"error",
)
if not any(i["body"].startswith("[error]") and "secret" in i["body"] for i in result.issues):
result.add_summary("- Security: OK")
def check_i18n(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that user-facing strings are wrapped in _().
Detects ``click.echo()`` calls with raw string literals that are not
wrapped in ``_()``. Only checks ``src/`` files, not tests or scripts.
"""
# Pattern: click.echo("...") or click.echo(f"...") without _() wrapper
raw_echo_re = re.compile(r'click\.echo\s*\(\s*["\']([^"\']+)["\']')
raw_fstring_re = re.compile(r'click\.echo\s*\(\s*f["\']')
# Also check click.ClickException and raise with string
raw_exception_re = re.compile(r'click\.ClickException\s*\(\s*["\']([^"\']+)["\']')
for f in files:
path = f.get("filename", "")
if not is_python_file(path) or not path.startswith("src/"):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Skip comments and docstrings
stripped = content.strip()
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
continue
# Check for raw strings in click.echo without _()
for regex, msg in [
(raw_echo_re, "click.echo() with raw string — wrap in _() for i18n"),
(raw_fstring_re, "click.echo() with f-string — wrap in _() for i18n"),
(raw_exception_re, "ClickException with raw string — wrap in _() for i18n"),
]:
if regex.search(content):
result.add_issue(path, current_line, msg, "warning")
if not any("i18n" in i["body"] for i in result.issues):
result.add_summary("- i18n: OK")
def check_resource_management(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for resource leaks: open() without with, subprocess without cleanup.
Detects:
- ``open()`` calls not in a ``with`` statement
- ``subprocess.Popen()`` without ``.wait()`` or ``.communicate()``
"""
# Pattern: open("...") not preceded by "with" on the same line
open_re = re.compile(r"(?<!with\s)\bopen\s*\(")
popen_re = re.compile(r"subprocess\.Popen\s*\(")
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Skip comments
if content.strip().startswith("#"):
continue
# Check for open() without with
if open_re.search(content) and "with " not in content:
result.add_issue(
path, current_line, "open() without with statement — potential resource leak", "warning"
)
# Check for Popen without communicate/wait on same line
if popen_re.search(content) and ".communicate" not in content and ".wait" not in content:
result.add_issue(
path,
current_line,
"subprocess.Popen() without immediate .communicate() or .wait() — ensure cleanup",
"warning",
)
if not any("resource" in i["body"].lower() for i in result.issues):
result.add_summary("- Resource management: OK")
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that no new function is excessively long (> 50 lines)."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
# Count consecutive added lines within a function
lines = patch.split("\n")
current_line = 0
func_start = 0
func_name = ""
added_in_func = 0
for line in lines:
if line.startswith("@@"):
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
func_name = ""
added_in_func = 0
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
func_match = re.match(r"\s*def\s+(\w+)\s*\(", content)
if func_match:
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
func_name = func_match.group(1)
func_start = current_line
added_in_func = 0
else:
added_in_func += 1
elif line.startswith(" ") or line.startswith("-"):
pass # context or removed line
# Check last function
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that documentation is updated for relevant changes."""
has_src_changes = any(
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
)
has_doc_changes = any(
f.get("filename", "").startswith("docs/") or f.get("filename", "") in ("README.md", "AGENTS.md", "CHANGELOG.md")
for f in files
)
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
# Check for TODO/FIXME in changed docs
todo_issues: list[str] = []
for f in files:
filename = f.get("filename", "")
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
# Can't check file content from PR API easily, but flag if patch adds TODO
patch = f.get("patch", "")
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
if has_src_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
elif has_ansible_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
elif has_tofu_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
elif has_workflow_changes and not has_doc_changes:
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
else:
result.add_summary("- Documentation: OK")
if todo_issues:
for issue in todo_issues:
result.add_summary(f"- Documentation: WARNING — {issue}")
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that tests are updated for source changes."""
has_src_changes = any(
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
)
has_test_changes = any(f.get("filename", "").startswith("tests/") for f in files)
if has_src_changes and not has_test_changes:
result.add_summary("- Tests: WARNING — source files changed but no test files updated")
else:
result.add_summary("- Tests: OK")
def check_commit_conventions(client: GiteaClient, pr_number: str, result: ReviewResult) -> None:
"""Check that PR commits follow conventional commit format.
Verifies that at least one commit on the PR branch matches the
conventional commit pattern (type: description). Merge commits
and revert commits are exempt.
"""
try:
commits = client.get_pr_commits(pr_number)
except APIError as e:
result.add_summary(f"- Commit conventions: ERROR — could not fetch commits: {e.message}")
return
if not commits:
result.add_summary("- Commit conventions: OK (no commits to check)")
return
from devx.config import CONVENTIONAL_RE
has_conventional = False
non_conventional: list[str] = []
for commit in commits:
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
# Skip merge commits and revert commits
if message.startswith(("Merge", "Revert")):
continue
if CONVENTIONAL_RE.match(message):
has_conventional = True
else:
non_conventional.append(message[:60])
if has_conventional:
result.add_summary("- Commit conventions: OK")
elif non_conventional:
result.add_summary(
f"- Commit conventions: WARNING — no conventional commit found. "
f"Non-conventional commits: {', '.join(non_conventional[:3])}"
)
else:
result.add_summary("- Commit conventions: OK (all commits are merges/reverts)")
def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
"""Run all review checks and return the result."""
result = ReviewResult()
try:
files = client.get_pr_files(pr_number)
except APIError as e:
result.add_summary(f"- ERROR: Could not fetch PR files: {e.message}")
return result
if not files:
result.add_summary("- No files changed in this PR")
return result
# Run all checks
check_architecture_compliance(files, result)
check_best_practices(files, result)
check_security(files, result)
check_i18n(files, result)
check_resource_management(files, result)
check_function_length(files, result)
check_documentation(files, result)
check_test_coverage(files, result)
check_commit_conventions(client, pr_number, result)
return result
def build_review_body(result: ReviewResult) -> str:
"""Build the review body text from the review result."""
lines = ["## Automated PR Review", ""]
for item in result.summary:
lines.append(item)
if result.issues:
lines.append("")
lines.append(f"**{len(result.issues)} issue(s) found:**")
lines.append("")
for issue in result.issues:
lines.append(f"- `{issue['path']}:{issue['new_position']}` — {issue['body']}")
else:
lines.append("")
lines.append("No issues found by automated checks.")
lines.append("")
lines.append("---")
lines.append("**Auto-merge:** If all CI checks pass, this PR will be merged automatically.")
return "\n".join(lines)
def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> dict[str, Any]:
"""Post the review to the PR.
Uses REQUEST_CHANGES when issues are found, COMMENT otherwise.
Never uses APPROVE — the bot shares the PR author's token, so
Gitea rejects self-approval. The actual APPROVE must come from
the manual review step.
"""
body = build_review_body(result)
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
comments = result.issues if result.has_issues else []
return client.create_review(pr_number, event=event, body=body, comments=comments)
def _post_manual_review(
client: GiteaClient,
pr_number: str,
event: str,
body: str | None,
checklist_confirmed: bool,
checklist_categories: str | None,
dry_run: bool,
owner: str | None = None,
repo_name: str | None = None,
) -> None:
"""Post a manual review with validation for APPROVE events.
When self-approval is rejected (reviewer token belongs to PR author),
falls back to the CI token (different user) if available.
"""
if not body or len(body) < 50:
raise click.ClickException(_("Review body must be at least 50 characters."))
if event == "APPROVE":
if not checklist_confirmed:
raise click.ClickException(
_("--checklist-confirmed is required for APPROVE events."),
)
cats = [c.strip() for c in (checklist_categories or "").split(",") if c.strip()]
cat_nums: list[int] = []
for c in cats:
try:
cat_nums.append(int(c))
except ValueError:
raise click.ClickException(
_("Invalid checklist category: {cat}. Must be numbers.", cat=c),
) from None
if len(cat_nums) < 8:
raise click.ClickException(
_("--checklist-categories must list at least 8 of 13 categories. Got {count}.", count=len(cat_nums)),
)
click.echo(f"Manual review event: {event}")
click.echo(f"Body: {body[:80]}...")
if checklist_confirmed:
click.echo(f"Checklist confirmed: {checklist_categories}")
if dry_run:
click.echo("\n[dry-run] Review not posted.")
return
try:
review = client.create_review(pr_number, event=event, body=body)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
# Self-approval not allowed (reviewer token belongs to PR author).
# Fall back to CI token (different user) if available.
ci_token = os.environ.get("CI_GITEA_API_TOKEN", "").strip()
if ci_token and owner and repo_name:
click.echo(_("Note: Self-approval not allowed with reviewer token. Retrying with CI token."))
ci_client = GiteaClient(GITEA_API_URL, ci_token, owner, repo_name)
try:
review = ci_client.create_review(pr_number, event=event, body=body)
except APIError:
click.echo(_("Note: CI token also cannot approve. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
click.echo(
_(
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
review_id=review_id,
pr_number=pr_number,
event=event,
)
)
@click.command()
@click.argument("pr_number")
@click.argument("repo")
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
@click.option(
"--event",
type=click.Choice(["APPROVE", "REQUEST_CHANGES", "COMMENT"], case_sensitive=False),
default=None,
help="Post a manual review with the given event (skips automated checks).",
)
@click.option("--body", default=None, help="Review body text (required with --event).")
@click.option(
"--checklist-confirmed",
is_flag=True,
default=False,
help="Attest that REVIEW_CHECKLIST.md categories were checked (required for APPROVE).",
)
@click.option(
"--checklist-categories",
default=None,
help="Comma-separated checklist category numbers (required for APPROVE, min 8 of 13).",
)
def main(
pr_number: str,
repo: str,
dry_run: bool,
event: str | None,
body: str | None,
checklist_confirmed: bool,
checklist_categories: str | None,
) -> None:
"""Run automated PR review and post results to Gitea.
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
With --event: posts a manual review (skips automated checks).
"""
try:
token = get_reviewer_token() if (event and event.upper() == "APPROVE") else get_ci_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if event is not None:
_post_manual_review(
client,
pr_number,
event.upper(),
body,
checklist_confirmed,
checklist_categories,
dry_run,
owner=owner,
repo_name=repo_name,
)
return
result = run_review(client, pr_number)
body = build_review_body(result)
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
click.echo(f"Review event: {event}")
click.echo(f"Issues found: {len(result.issues)}")
click.echo("")
click.echo(body)
if dry_run:
click.echo("\n[dry-run] Review not posted.")
return
try:
review = post_review(client, pr_number, result)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
click.echo(
_(
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
review_id=review_id,
pr_number=pr_number,
event=event,
num_comments=len(result.issues),
)
)
if __name__ == "__main__": # pragma: no cover
main()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# Implements: REQ-1
"""Validate that a PR has a spec file with required sections and acceptance criteria.
Spec-driven development gate. Runs in CI before expensive jobs.
Validates:
1. A spec file exists at ``docs/specs/<TASK-ID>.md`` (TASK-ID extracted from branch).
2. The spec contains required sections: Problem, Approach, Test Plan, Deploy Plan, Rollback Plan.
3. The spec contains REQ-ID lines (``REQ-N: <description>``).
4. The spec contains an Acceptance Criteria checklist with at least one item.
5. All acceptance criteria checkboxes are checked (``- [x]``).
Usage:
python -m devx.ci.validate_spec --branch OBL-INFRA-531-fix-foo
In CI, also pass ``--github-output`` to set ``spec-valid`` and ``spec-path``
for downstream steps.
"""
from __future__ import annotations
import re
from pathlib import Path
import click
from dotenv import load_dotenv
from devx.ci._shared import extract_task_id, write_github_output
from devx.i18n import _
load_dotenv()
REQUIRED_SECTIONS = [
"## Problem",
"## Approach",
"## Test Plan",
"## Deploy Plan",
"## Rollback Plan",
"## Acceptance Criteria",
]
REQ_ID_RE = re.compile(r"^REQ-\d+:\s+.+", re.MULTILINE)
AC_CHECKED_RE = re.compile(r"^\s*- \[x\]\s+.+", re.MULTILINE)
AC_UNCHECKED_RE = re.compile(r"^\s*- \[ \]\s+.+", re.MULTILINE)
def find_spec_file(task_id: str, specs_dir: str = "docs/specs") -> Path | None:
"""Find the spec file for the given task ID.
Looks for ``docs/specs/<TASK-ID>.md`` (case-insensitive filename).
Returns the Path if found, None otherwise.
"""
base = Path(specs_dir)
if not base.is_dir():
return None
# Exact match (case-insensitive)
for p in base.glob("*.md"):
if p.stem.upper() == task_id.upper():
return p
return None
def validate_spec_content(content: str) -> list[str]:
"""Validate spec content and return a list of error messages.
Returns an empty list if the spec is valid.
"""
errors: list[str] = []
# Check required sections
for section in REQUIRED_SECTIONS:
if section not in content:
errors.append(_("Missing required section: {section}", section=section))
# Check for at least one REQ-ID
req_ids = REQ_ID_RE.findall(content)
if not req_ids:
errors.append(_("No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>')."))
# Check acceptance criteria has at least one item
checked = AC_CHECKED_RE.findall(content)
unchecked = AC_UNCHECKED_RE.findall(content)
if not checked and not unchecked:
errors.append(_("Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'."))
elif unchecked:
errors.append(
_(
"Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.",
count=len(unchecked),
)
)
return errors
@click.command()
@click.option("--branch", required=True, help=_("Branch name (e.g., OBL-INFRA-531-fix-foo)"))
@click.option("--specs-dir", default="docs/specs", help=_("Directory containing spec files"))
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help=_("Write results to $GITHUB_OUTPUT"),
)
@click.option("--allow-missing", is_flag=True, default=False, help=_("Allow missing spec (warn only, don't fail)"))
def cli(branch: str, specs_dir: str, github_output: bool, allow_missing: bool) -> None:
"""Validate that a spec file exists and has required content."""
task_id = extract_task_id(branch)
if not task_id:
msg = _("No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.", branch=branch)
if allow_missing:
click.echo(f"WARNING: {msg}")
if github_output:
write_github_output("spec-valid", "false")
write_github_output("spec-path", "")
return
raise click.ClickException(msg)
spec_path = find_spec_file(task_id, specs_dir)
if spec_path is None:
msg = _(
"No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md",
task_id=task_id,
dir=specs_dir,
)
if allow_missing:
click.echo(f"WARNING: {msg}")
if github_output:
write_github_output("spec-valid", "false")
write_github_output("spec-path", "")
return
raise click.ClickException(msg)
content = spec_path.read_text(encoding="utf-8")
errors = validate_spec_content(content)
if github_output:
write_github_output("spec-valid", "true" if not errors else "false")
write_github_output("spec-path", str(spec_path))
if errors:
click.echo("", err=True)
click.echo("=" * 60, err=True)
click.echo(f"Spec validation FAILED for {spec_path}:", err=True)
click.echo("=" * 60, err=True)
for e in errors:
click.echo(f" - {e}", err=True)
raise click.ClickException(_("Spec validation failed."))
click.echo(_("[spec-check] Spec validated: {path}", path=spec_path))
if __name__ == "__main__": # pragma: no cover
cli()
-7
View File
@@ -116,13 +116,6 @@ def ci_post_merge(args: tuple[str, ...]) -> None:
_run_module("devx.ci.post_merge", list(args))
@ci.command("pr-review")
@click.argument("args", nargs=-1)
def ci_pr_review(args: tuple[str, ...]) -> None:
"""Run automated PR review."""
_run_module("devx.ci.pr_review", list(args))
@ci.command("publish")
@click.argument("args", nargs=-1)
def ci_publish(args: tuple[str, ...]) -> None:
+1 -11
View File
@@ -109,7 +109,7 @@ devx-ensure-venv:
fi
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-rebase devx-pr-rebase
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
@@ -171,16 +171,6 @@ devx-pr-label:
$(if $(PR),--pr $(PR)) \
--label $(or $(LABEL),ready-to-merge)
# Usage: make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13
# make devx-pr-review PR=42 EVENT=REQUEST_CHANGES BODY="..."
# make devx-pr-review PR=42 (auto review)
devx-pr-review:
@$(DEVX_PYTHON) -m devx.ci.pr_review \
$(PR) $(DEVX_REPO_OWNER)/$(DEVX_REPO_NAME) \
$(if $(EVENT),--event $(EVENT)) \
$(if $(BODY),--body "$(BODY)") \
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
# Rebase current branch onto origin/master and force-push
# Usage: make devx-rebase
# make devx-rebase NO_PUSH=1
+2 -16
View File
@@ -2,19 +2,16 @@
Centralizes Gitea/Vikunja token discovery with role-based environment
variable names and backwards compatibility with the legacy
``CI_GITEA_TOKEN`` / ``REVIEW_GITEA_TOKEN`` naming convention.
``CI_GITEA_TOKEN`` naming convention.
Roles:
- ``CI_GITEA_API_TOKEN``: CI workflows (read actions, post status, merge, etc.)
- ``REVIEWER_GITEA_API_TOKEN``: PR approval reviews (must be a different user
from the PR author for Gitea to accept the review as an approval)
- ``DEVELOPER_GITEA_API_TOKEN``: local development tools (create-task,
create-pr, setup, etc.)
Fallbacks:
- New role names are checked first.
- Legacy names (``CI_GITEA_TOKEN``, ``REVIEW_GITEA_TOKEN``) are accepted for
backwards compatibility.
- Legacy names (``CI_GITEA_TOKEN``) are accepted for backwards compatibility.
- If no role-specific token is set, the generic CI tokens are tried last.
"""
@@ -28,12 +25,6 @@ from devx.i18n import _
# Token environment variable names, in lookup priority order.
CI_TOKEN_NAMES = ["CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"]
REVIEWER_TOKEN_NAMES = [
"REVIEWER_GITEA_API_TOKEN",
# Legacy name used before role-based tokens.
"REVIEW_GITEA_TOKEN",
*CI_TOKEN_NAMES,
]
DEVELOPER_TOKEN_NAMES = ["DEVELOPER_GITEA_API_TOKEN", *CI_TOKEN_NAMES]
VIKUNJA_TOKEN_NAMES = ["VIKUNJA_TOKEN"]
@@ -61,11 +52,6 @@ def get_ci_token() -> str:
return get_token(*CI_TOKEN_NAMES)
def get_reviewer_token() -> str:
"""Resolve the reviewer Gitea API token used for PR approvals."""
return get_token(*REVIEWER_TOKEN_NAMES)
def get_developer_token() -> str:
"""Resolve the developer Gitea API token used for local tooling."""
return get_token(*DEVELOPER_TOKEN_NAMES)
+543 -239
View File
@@ -151,22 +151,6 @@
"ru": "\nResult: {status}",
"zh": "\nResult: {status}"
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"pl": "\nRecenzja #{review_id} opublikowana na PR #{pr_number} ze zdarzeniem '{event}' ({num_comments} komentarzy w tekście).",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
},
"\nRun with --fix to auto-update version references.": {
"bg": "",
"de": "",
@@ -183,6 +167,14 @@
"ru": "\nTag → Commit alignment:",
"zh": "\nTag → Commit alignment:"
},
"\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n": {
"bg": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"de": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"en": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"pl": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"ru": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"zh": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n"
},
"\nUntagged release commits:": {
"bg": "\nUntagged release commits:",
"de": "\nUntagged release commits:",
@@ -447,6 +439,14 @@
"ru": " FAILED to delete: {version}",
"zh": " FAILED to delete: {version}"
},
" Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'": {
"bg": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"de": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"en": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"pl": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"ru": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"zh": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'"
},
" Fixed {fixes} version ref(s) in {file}": {
"bg": "",
"de": "",
@@ -719,22 +719,6 @@
"ru": " {version} (created: {created})",
"zh": " {version} (created: {created})"
},
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
},
"--checklist-confirmed is required for APPROVE events.": {
"bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--checklist-confirmed is required for APPROVE events.",
"en": "--checklist-confirmed is required for APPROVE events.",
"pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events."
},
"--push requires --registry": {
"bg": "--push requires --registry",
"de": "--push requires --registry",
@@ -767,6 +751,38 @@
"ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}"
},
"Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.": {
"bg": "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.",
"de": "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.",
"en": "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.",
"pl": "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.",
"ru": "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.",
"zh": "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge."
},
"Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.": {
"bg": "Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.",
"de": "Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.",
"en": "Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.",
"pl": "Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.",
"ru": "Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.",
"zh": "Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'."
},
"Action to perform": {
"bg": "Action to perform",
"de": "Action to perform",
"en": "Action to perform",
"pl": "Action to perform",
"ru": "Action to perform",
"zh": "Action to perform"
},
"Add @patch(\"subprocess.run\") or patch the calling function to fix this.": {
"bg": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"de": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"en": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"pl": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"ru": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"zh": "Add @patch(\"subprocess.run\") or patch the calling function to fix this."
},
"Added label '{label}' to PR #{pr}.": {
"bg": "Added label '{label}' to PR #{pr}.",
"de": "Added label '{label}' to PR #{pr}.",
@@ -783,6 +799,14 @@
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
},
"Additional excluded patterns (in addition to defaults)": {
"bg": "Additional excluded patterns (in addition to defaults)",
"de": "Additional excluded patterns (in addition to defaults)",
"en": "Additional excluded patterns (in addition to defaults)",
"pl": "Additional excluded patterns (in addition to defaults)",
"ru": "Additional excluded patterns (in addition to defaults)",
"zh": "Additional excluded patterns (in addition to defaults)"
},
"Allow empty tag (PR mode where SHA is concrete).": {
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
@@ -791,6 +815,14 @@
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
},
"Allow missing spec (warn only, don't fail)": {
"bg": "Allow missing spec (warn only, don't fail)",
"de": "Allow missing spec (warn only, don't fail)",
"en": "Allow missing spec (warn only, don't fail)",
"pl": "Allow missing spec (warn only, don't fail)",
"ru": "Allow missing spec (warn only, don't fail)",
"zh": "Allow missing spec (warn only, don't fail)"
},
"Another runner failed. Stopping this runner early.": {
"bg": "Друг runner се провали. Спиране на този runner по-рано.",
"de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.",
@@ -863,6 +895,14 @@
"ru": "Badges pushed to badges branch",
"zh": "Badges pushed to badges branch"
},
"Base ref for diff": {
"bg": "Base ref for diff",
"de": "Base ref for diff",
"en": "Base ref for diff",
"pl": "Base ref for diff",
"ru": "Base ref for diff",
"zh": "Base ref for diff"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
@@ -911,6 +951,14 @@
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
},
"Branch name (auto-fetched from PR if not given)": {
"bg": "Branch name (auto-fetched from PR if not given)",
"de": "Branch name (auto-fetched from PR if not given)",
"en": "Branch name (auto-fetched from PR if not given)",
"pl": "Branch name (auto-fetched from PR if not given)",
"ru": "Branch name (auto-fetched from PR if not given)",
"zh": "Branch name (auto-fetched from PR if not given)"
},
"Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
"de": "Branch name (e.g., DEVX-256-fix-foo)",
@@ -919,6 +967,14 @@
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
},
"Branch name (e.g., OBL-INFRA-531-fix-foo)": {
"bg": "Branch name (e.g., OBL-INFRA-531-fix-foo)",
"de": "Branch name (e.g., OBL-INFRA-531-fix-foo)",
"en": "Branch name (e.g., OBL-INFRA-531-fix-foo)",
"pl": "Branch name (e.g., OBL-INFRA-531-fix-foo)",
"ru": "Branch name (e.g., OBL-INFRA-531-fix-foo)",
"zh": "Branch name (e.g., OBL-INFRA-531-fix-foo)"
},
"Branch name must contain a task ID.": {
"bg": "Branch name must contain a task ID.",
"de": "Branch name must contain a task ID.",
@@ -959,6 +1015,30 @@
"ru": "CI checks failed.",
"zh": "CI checks failed."
},
"CI run ID (for set-failed/set-passed)": {
"bg": "CI run ID (for set-failed/set-passed)",
"de": "CI run ID (for set-failed/set-passed)",
"en": "CI run ID (for set-failed/set-passed)",
"pl": "CI run ID (for set-failed/set-passed)",
"ru": "CI run ID (for set-failed/set-passed)",
"zh": "CI run ID (for set-failed/set-passed)"
},
"CI run ID that triggered the publish": {
"bg": "CI run ID that triggered the publish",
"de": "CI run ID that triggered the publish",
"en": "CI run ID that triggered the publish",
"pl": "CI run ID that triggered the publish",
"ru": "CI run ID that triggered the publish",
"zh": "CI run ID that triggered the publish"
},
"CI_GITEA_API_TOKEN not set: {error}": {
"bg": "CI_GITEA_API_TOKEN not set: {error}",
"de": "CI_GITEA_API_TOKEN not set: {error}",
"en": "CI_GITEA_API_TOKEN not set: {error}",
"pl": "CI_GITEA_API_TOKEN not set: {error}",
"ru": "CI_GITEA_API_TOKEN not set: {error}",
"zh": "CI_GITEA_API_TOKEN not set: {error}"
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
@@ -1143,6 +1223,14 @@
"ru": "",
"zh": ""
},
"CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.": {
"bg": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"de": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"en": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"pl": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"ru": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"zh": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function."
},
"Cloned existing wiki.": {
"bg": "",
"de": "",
@@ -1255,6 +1343,14 @@
"ru": "Не удалось определить текущую ветку: {error}",
"zh": "无法检测当前分支: {error}"
},
"Could not determine branch name from PR #{pr}": {
"bg": "Could not determine branch name from PR #{pr}",
"de": "Could not determine branch name from PR #{pr}",
"en": "Could not determine branch name from PR #{pr}",
"pl": "Could not determine branch name from PR #{pr}",
"ru": "Could not determine branch name from PR #{pr}",
"zh": "Could not determine branch name from PR #{pr}"
},
"Could not determine head SHA for PR #{pr_number}.": {
"bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "Could not determine head SHA for PR #{pr_number}.",
@@ -1311,6 +1407,14 @@
"ru": "Could not find __version__ in {file}",
"zh": "Could not find __version__ in {file}"
},
"Could not find pinned version for {pkg}": {
"bg": "Could not find pinned version for {pkg}",
"de": "Could not find pinned version for {pkg}",
"en": "Could not find pinned version for {pkg}",
"pl": "Could not find pinned version for {pkg}",
"ru": "Could not find pinned version for {pkg}",
"zh": "Could not find pinned version for {pkg}"
},
"Could not parse test execution time from output.": {
"bg": "Could not parse test execution time from output.",
"de": "Could not parse test execution time from output.",
@@ -1359,6 +1463,22 @@
"ru": "Dependencies must have documentation comments.",
"zh": "Dependencies must have documentation comments."
},
"Directory containing Ansible roles": {
"bg": "Directory containing Ansible roles",
"de": "Directory containing Ansible roles",
"en": "Directory containing Ansible roles",
"pl": "Directory containing Ansible roles",
"ru": "Directory containing Ansible roles",
"zh": "Directory containing Ansible roles"
},
"Directory containing spec files": {
"bg": "Directory containing spec files",
"de": "Directory containing spec files",
"en": "Directory containing spec files",
"pl": "Directory containing spec files",
"ru": "Directory containing spec files",
"zh": "Directory containing spec files"
},
"Directory to scan (default: tests/integration). Can be repeated.": {
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
@@ -1495,6 +1615,14 @@
"ru": "Failed images: {names}",
"zh": "Failed images: {names}"
},
"Failed to create branch: {error}": {
"bg": "Failed to create branch: {error}",
"de": "Failed to create branch: {error}",
"en": "Failed to create branch: {error}",
"pl": "Failed to create branch: {error}",
"ru": "Failed to create branch: {error}",
"zh": "Failed to create branch: {error}"
},
"Failed to create issue via tea: {error}": {
"bg": "Failed to create issue via tea: {error}",
"de": "Failed to create issue via tea: {error}",
@@ -1511,6 +1639,14 @@
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"Failed to fetch PR #{pr}: {error}": {
"bg": "Failed to fetch PR #{pr}: {error}",
"de": "Failed to fetch PR #{pr}: {error}",
"en": "Failed to fetch PR #{pr}: {error}",
"pl": "Failed to fetch PR #{pr}: {error}",
"ru": "Failed to fetch PR #{pr}: {error}",
"zh": "Failed to fetch PR #{pr}: {error}"
},
"Failed to list versions for {name}: {error}": {
"bg": "Failed to list versions for {name}: {error}",
"de": "Failed to list versions for {name}: {error}",
@@ -1535,6 +1671,22 @@
"ru": "Не удалось запустить ssh-agent: {error}",
"zh": "启动 ssh-agent 失败: {error}"
},
"Failed to update PR #{pr}: {error}": {
"bg": "Failed to update PR #{pr}: {error}",
"de": "Failed to update PR #{pr}: {error}",
"en": "Failed to update PR #{pr}: {error}",
"pl": "Failed to update PR #{pr}: {error}",
"ru": "Failed to update PR #{pr}: {error}",
"zh": "Failed to update PR #{pr}: {error}"
},
"Failed to update {file}": {
"bg": "Failed to update {file}",
"de": "Failed to update {file}",
"en": "Failed to update {file}",
"pl": "Failed to update {file}",
"ru": "Failed to update {file}",
"zh": "Failed to update {file}"
},
"Fetch failed: {error}": {
"bg": "Fetch failed: {error}",
"de": "Fetch failed: {error}",
@@ -1559,6 +1711,22 @@
"ru": "Fetching origin/master...",
"zh": "Fetching origin/master..."
},
"Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.": {
"bg": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"de": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"en": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"pl": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"ru": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"zh": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function."
},
"Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n": {
"bg": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"de": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"en": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"pl": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"ru": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"zh": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n"
},
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
@@ -1727,6 +1895,22 @@
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
},
"Head ref for diff": {
"bg": "Head ref for diff",
"de": "Head ref for diff",
"en": "Head ref for diff",
"pl": "Head ref for diff",
"ru": "Head ref for diff",
"zh": "Head ref for diff"
},
"Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.": {
"bg": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"de": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"en": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"pl": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"ru": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"zh": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import."
},
"Host Docker not available, starting local dockerd...": {
"bg": "Хост Docker не е наличен, стартиране на локален dockerd...",
"de": "Host-Docker nicht verfügbar, lokaler dockerd wird gestartet...",
@@ -1791,13 +1975,21 @@
"ru": "Integration tests passed.",
"zh": "Integration tests passed."
},
"Invalid checklist category: {cat}. Must be numbers.": {
"bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "Invalid checklist category: {cat}. Must be numbers.",
"en": "Invalid checklist category: {cat}. Must be numbers.",
"pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers."
"Invalid repo format: {repo}": {
"bg": "Invalid repo format: {repo}",
"de": "Invalid repo format: {repo}",
"en": "Invalid repo format: {repo}",
"pl": "Invalid repo format: {repo}",
"ru": "Invalid repo format: {repo}",
"zh": "Invalid repo format: {repo}"
},
"Invalid repo format: {repo}. Expected owner/name.": {
"bg": "Invalid repo format: {repo}. Expected owner/name.",
"de": "Invalid repo format: {repo}. Expected owner/name.",
"en": "Invalid repo format: {repo}. Expected owner/name.",
"pl": "Invalid repo format: {repo}. Expected owner/name.",
"ru": "Invalid repo format: {repo}. Expected owner/name.",
"zh": "Invalid repo format: {repo}. Expected owner/name."
},
"Items input must be a JSON array, got {type}": {
"bg": "Входните данни трябва да са JSON масив, получено {type}",
@@ -1879,6 +2071,22 @@
"ru": "Manifest must be a JSON list",
"zh": "Manifest must be a JSON list"
},
"Max files changed (excluded files not counted)": {
"bg": "Max files changed (excluded files not counted)",
"de": "Max files changed (excluded files not counted)",
"en": "Max files changed (excluded files not counted)",
"pl": "Max files changed (excluded files not counted)",
"ru": "Max files changed (excluded files not counted)",
"zh": "Max files changed (excluded files not counted)"
},
"Max lines changed (excluded files not counted)": {
"bg": "Max lines changed (excluded files not counted)",
"de": "Max lines changed (excluded files not counted)",
"en": "Max lines changed (excluded files not counted)",
"pl": "Max lines changed (excluded files not counted)",
"ru": "Max lines changed (excluded files not counted)",
"zh": "Max lines changed (excluded files not counted)"
},
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
@@ -1887,6 +2095,14 @@
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
},
"Missing required section: {section}": {
"bg": "Missing required section: {section}",
"de": "Missing required section: {section}",
"en": "Missing required section: {section}",
"pl": "Missing required section: {section}",
"ru": "Missing required section: {section}",
"zh": "Missing required section: {section}"
},
"Missing tests for changed files.": {
"bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.",
@@ -1911,6 +2127,14 @@
"ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}"
},
"New version to pin": {
"bg": "New version to pin",
"de": "New version to pin",
"en": "New version to pin",
"pl": "New version to pin",
"ru": "New version to pin",
"zh": "New version to pin"
},
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
@@ -1951,6 +2175,14 @@
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"Nightly gate failed — staging deploy blocked.": {
"bg": "Nightly gate failed — staging deploy blocked.",
"de": "Nightly gate failed — staging deploy blocked.",
"en": "Nightly gate failed — staging deploy blocked.",
"pl": "Nightly gate failed — staging deploy blocked.",
"ru": "Nightly gate failed — staging deploy blocked.",
"zh": "Nightly gate failed — staging deploy blocked."
},
"No CI checks found for commit {sha}.": {
"bg": "No CI checks found for commit {sha}.",
"de": "No CI checks found for commit {sha}.",
@@ -1967,6 +2199,14 @@
"ru": "",
"zh": ""
},
"No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>').": {
"bg": "No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>').",
"de": "No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>').",
"en": "No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>').",
"pl": "No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>').",
"ru": "No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>').",
"zh": "No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: <description>')."
},
"No badge SVG files generated": {
"bg": "No badge SVG files generated",
"de": "No badge SVG files generated",
@@ -2047,6 +2287,14 @@
"ru": "",
"zh": ""
},
"No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md": {
"bg": "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md",
"de": "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md",
"en": "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md",
"pl": "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md",
"ru": "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md",
"zh": "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md"
},
"No staged changes — version and changelog already up to date.": {
"bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.",
@@ -2079,6 +2327,22 @@
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
},
"No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description."
},
"No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.",
"en": "No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.",
"pl": "No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.",
"ru": "No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description.",
"zh": "No task ID found in branch name '{branch}'. Expected format: <PREFIX>-N-description."
},
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
@@ -2119,30 +2383,6 @@
"ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}."
},
"Note: CI token also cannot approve. Posting COMMENT instead.": {
"bg": "Забележка: CI тоукънът също не може да одобри. Публикуване на COMMENT вместо това.",
"de": "Hinweis: CI-Token kann ebenfalls nicht genehmigen. COMMENT wird stattdessen gesendet.",
"en": "Note: CI token also cannot approve. Posting COMMENT instead.",
"pl": "Uwaga: Token CI również nie może zatwierdzić. Publikowanie COMMENT zamiast tego.",
"ru": "Примечание: CI токен также не может одобрить. Публикация COMMENT вместо этого.",
"zh": "注意:CI 令牌也无法批准。改为发布 COMMENT。"
},
"Note: Self-approval not allowed with reviewer token. Retrying with CI token.": {
"bg": "Забележка: Само-одобрението не е разрешено с тоукън на рецензента. Повторен опит с CI тоукън.",
"de": "Hinweis: Selbstgenehmigung mit Reviewer-Token nicht erlaubt. Wiederholung mit CI-Token.",
"en": "Note: Self-approval not allowed with reviewer token. Retrying with CI token.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone tokenem recenzenta. Ponawianie tokenem CI.",
"ru": "Примечание: Самоодобрение токеном ревьюера не разрешено. Повторная попытка с CI токеном.",
"zh": "注意:不允许使用审阅者令牌进行自我批准。正在使用 CI 令牌重试。"
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Забележка: Само-одобрението не е разрешено. Публикуване на COMMENT вместо това.",
"de": "Hinweis: Selbstgenehmigung nicht erlaubt. COMMENT wird stattdessen gesendet.",
"en": "Note: Self-approval not allowed. Posting COMMENT instead.",
"pl": "Uwaga: Samo-zatwierdzenie niedozwolone. Publikowanie COMMENT zamiast tego.",
"ru": "Примечание: Самоодобрение не разрешено. Публикация COMMENT вместо этого.",
"zh": "注意:不允许自我批准。改为发布 COMMENT。"
},
"Nothing to push.": {
"bg": "Nothing to push.",
"de": "Nothing to push.",
@@ -2263,6 +2503,22 @@
"ru": "PR уже существует: #{index} — {url}",
"zh": "PR 已存在: #{index} — {url}"
},
"PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.": {
"bg": "PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.",
"de": "PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.",
"en": "PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.",
"pl": "PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.",
"ru": "PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files.",
"zh": "PR has {file_count} files changed (max {max_files}). Excluded: {excluded_count} files."
},
"PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.": {
"bg": "PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.",
"de": "PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.",
"en": "PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.",
"pl": "PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.",
"ru": "PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files.",
"zh": "PR has {line_count} lines changed (max {max_lines}). Excluded: {excluded_count} files."
},
"PR number (to fetch title from Gitea)": {
"bg": "PR number (to fetch title from Gitea)",
"de": "PR number (to fetch title from Gitea)",
@@ -2279,6 +2535,30 @@
"ru": "PR number must be an integer, got: {pr_number}",
"zh": "PR number must be an integer, got: {pr_number}"
},
"PR number to fix": {
"bg": "PR number to fix",
"de": "PR number to fix",
"en": "PR number to fix",
"pl": "PR number to fix",
"ru": "PR number to fix",
"zh": "PR number to fix"
},
"PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).": {
"bg": "PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).",
"de": "PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).",
"en": "PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).",
"pl": "PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).",
"ru": "PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines).",
"zh": "PR size OK: {file_count} files, {line_count} lines (max {max_files} files, {max_lines} lines)."
},
"PR size check failed.": {
"bg": "PR size check failed.",
"de": "PR size check failed.",
"en": "PR size check failed.",
"pl": "PR size check failed.",
"ru": "PR size check failed.",
"zh": "PR size check failed."
},
"PR title (auto-fetched if --pr-number given)": {
"bg": "PR title (auto-fetched if --pr-number given)",
"de": "PR title (auto-fetched if --pr-number given)",
@@ -2327,6 +2607,14 @@
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"Package name to bump (e.g., grm, sso-bridge)": {
"bg": "Package name to bump (e.g., grm, sso-bridge)",
"de": "Package name to bump (e.g., grm, sso-bridge)",
"en": "Package name to bump (e.g., grm, sso-bridge)",
"pl": "Package name to bump (e.g., grm, sso-bridge)",
"ru": "Package name to bump (e.g., grm, sso-bridge)",
"zh": "Package name to bump (e.g., grm, sso-bridge)"
},
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
@@ -2495,6 +2783,14 @@
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"Real subprocess call(s) detected in test '{test}' without @patch:": {
"bg": "Real subprocess call(s) detected in test '{test}' without @patch:",
"de": "Real subprocess call(s) detected in test '{test}' without @patch:",
"en": "Real subprocess call(s) detected in test '{test}' without @patch:",
"pl": "Real subprocess call(s) detected in test '{test}' without @patch:",
"ru": "Real subprocess call(s) detected in test '{test}' without @patch:",
"zh": "Real subprocess call(s) detected in test '{test}' without @patch:"
},
"Rebase attempt {n}/3 failed: {err}": {
"bg": "Rebase attempt {n}/3 failed: {err}",
"de": "Rebase attempt {n}/3 failed: {err}",
@@ -2639,14 +2935,6 @@
"ru": "Отсутствуют обязательные инструменты.",
"zh": "缺少必需的工具。"
},
"Review body must be at least 50 characters.": {
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
"en": "Review body must be at least 50 characters.",
"pl": "Review body must be at least 50 characters.",
"ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters."
},
"Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}",
@@ -2743,6 +3031,30 @@
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
},
"Show what would be done without creating PR": {
"bg": "Show what would be done without creating PR",
"de": "Show what would be done without creating PR",
"en": "Show what would be done without creating PR",
"pl": "Show what would be done without creating PR",
"ru": "Show what would be done without creating PR",
"zh": "Show what would be done without creating PR"
},
"Show what would change without updating": {
"bg": "Show what would change without updating",
"de": "Show what would change without updating",
"en": "Show what would change without updating",
"pl": "Show what would change without updating",
"ru": "Show what would change without updating",
"zh": "Show what would change without updating"
},
"Single platform to test against": {
"bg": "Single platform to test against",
"de": "Single platform to test against",
"en": "Single platform to test against",
"pl": "Single platform to test against",
"ru": "Single platform to test against",
"zh": "Single platform to test against"
},
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
@@ -2775,6 +3087,22 @@
"ru": "Skipping — runner index {runner_index} > max runners {max_runners}",
"zh": "Skipping — runner index {runner_index} > max runners {max_runners}"
},
"Source repo that published (owner/name)": {
"bg": "Source repo that published (owner/name)",
"de": "Source repo that published (owner/name)",
"en": "Source repo that published (owner/name)",
"pl": "Source repo that published (owner/name)",
"ru": "Source repo that published (owner/name)",
"zh": "Source repo that published (owner/name)"
},
"Spec validation failed.": {
"bg": "Spec validation failed.",
"de": "Spec validation failed.",
"en": "Spec validation failed.",
"pl": "Spec validation failed.",
"ru": "Spec validation failed.",
"zh": "Spec validation failed."
},
"Synced to latest origin/{branch}": {
"bg": "Synced to latest origin/{branch}",
"de": "Synced to latest origin/{branch}",
@@ -2839,6 +3167,14 @@
"ru": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details.",
"zh": "Tag {tag} already exists but points to {tag_commit} (expected HEAD {head_commit}). This indicates a tag/commit misalignment. Run 'python3 -m devx.ci.release --verify' for details."
},
"Target repo (owner/name) to create PR in": {
"bg": "Target repo (owner/name) to create PR in",
"de": "Target repo (owner/name) to create PR in",
"en": "Target repo (owner/name) to create PR in",
"pl": "Target repo (owner/name) to create PR in",
"ru": "Target repo (owner/name) to create PR in",
"zh": "Target repo (owner/name) to create PR in"
},
"Task ID: {task_id}": {
"bg": "Task ID: {task_id}",
"de": "Task ID: {task_id}",
@@ -2855,6 +3191,22 @@
"ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
"zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
},
"Test isolation check FAILED: {count} violation(s) in {files} file(s).": {
"bg": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"de": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"en": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"pl": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"ru": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"zh": "Test isolation check FAILED: {count} violation(s) in {files} file(s)."
},
"Test isolation check passed with {count} advisory warning(s) in {files} file(s).": {
"bg": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"de": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"en": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"pl": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"ru": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"zh": "Test isolation check passed with {count} advisory warning(s) in {files} file(s)."
},
"Test isolation check passed: {count} test files analyzed, no violations found.": {
"bg": "Проверката за изолация на тестове премина: анализирани са {count} тестови файла, няма нарушения.",
"de": "Testisolationsprüfung bestanden: {count} Testdateien analysiert, keine Verstöße gefunden.",
@@ -2887,6 +3239,14 @@
"ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s."
},
"Transitive-subprocess advisories (runtime audit is authoritative):": {
"bg": "Transitive-subprocess advisories (runtime audit is authoritative):",
"de": "Transitive-subprocess advisories (runtime audit is authoritative):",
"en": "Transitive-subprocess advisories (runtime audit is authoritative):",
"pl": "Transitive-subprocess advisories (runtime audit is authoritative):",
"ru": "Transitive-subprocess advisories (runtime audit is authoritative):",
"zh": "Transitive-subprocess advisories (runtime audit is authoritative):"
},
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
@@ -3199,6 +3559,14 @@
"ru": "Записать deploy-ref в файл $GITHUB_OUTPUT.",
"zh": "将 deploy-ref 写入 $GITHUB_OUTPUT 文件。"
},
"Write results to $GITHUB_OUTPUT": {
"bg": "Write results to $GITHUB_OUTPUT",
"de": "Write results to $GITHUB_OUTPUT",
"en": "Write results to $GITHUB_OUTPUT",
"pl": "Write results to $GITHUB_OUTPUT",
"ru": "Write results to $GITHUB_OUTPUT",
"zh": "Write results to $GITHUB_OUTPUT"
},
"Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
@@ -3255,6 +3623,14 @@
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
},
"[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)": {
"bg": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"de": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"en": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
},
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
@@ -3271,6 +3647,46 @@
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
},
"[dep-pr] Bumping {pkg} from {old} to {new} in {file}": {
"bg": "[dep-pr] Bumping {pkg} from {old} to {new} in {file}",
"de": "[dep-pr] Bumping {pkg} from {old} to {new} in {file}",
"en": "[dep-pr] Bumping {pkg} from {old} to {new} in {file}",
"pl": "[dep-pr] Bumping {pkg} from {old} to {new} in {file}",
"ru": "[dep-pr] Bumping {pkg} from {old} to {new} in {file}",
"zh": "[dep-pr] Bumping {pkg} from {old} to {new} in {file}"
},
"[dep-pr] Could not find pinned version for {pkg} in infra repo.": {
"bg": "[dep-pr] Could not find pinned version for {pkg} in infra repo.",
"de": "[dep-pr] Could not find pinned version for {pkg} in infra repo.",
"en": "[dep-pr] Could not find pinned version for {pkg} in infra repo.",
"pl": "[dep-pr] Could not find pinned version for {pkg} in infra repo.",
"ru": "[dep-pr] Could not find pinned version for {pkg} in infra repo.",
"zh": "[dep-pr] Could not find pinned version for {pkg} in infra repo."
},
"[dep-pr] Created PR #{number}: {title}": {
"bg": "[dep-pr] Created PR #{number}: {title}",
"de": "[dep-pr] Created PR #{number}: {title}",
"en": "[dep-pr] Created PR #{number}: {title}",
"pl": "[dep-pr] Created PR #{number}: {title}",
"ru": "[dep-pr] Created PR #{number}: {title}",
"zh": "[dep-pr] Created PR #{number}: {title}"
},
"[dep-pr] PR already exists: #{number}": {
"bg": "[dep-pr] PR already exists: #{number}",
"de": "[dep-pr] PR already exists: #{number}",
"en": "[dep-pr] PR already exists: #{number}",
"pl": "[dep-pr] PR already exists: #{number}",
"ru": "[dep-pr] PR already exists: #{number}",
"zh": "[dep-pr] PR already exists: #{number}"
},
"[dep-pr] {pkg} already at {version} — no PR needed.": {
"bg": "[dep-pr] {pkg} already at {version} — no PR needed.",
"de": "[dep-pr] {pkg} already at {version} — no PR needed.",
"en": "[dep-pr] {pkg} already at {version} — no PR needed.",
"pl": "[dep-pr] {pkg} already at {version} — no PR needed.",
"ru": "[dep-pr] {pkg} already at {version} — no PR needed.",
"zh": "[dep-pr] {pkg} already at {version} — no PR needed."
},
"[docker-login] Logged in to {registry}.": {
"bg": "[docker-login] Влязъл в {registry}.",
"de": "[docker-login] Angemeldet bei {registry}.",
@@ -3367,6 +3783,46 @@
"ru": "[dry-run] Would update {init}",
"zh": "[dry-run] Would update {init}"
},
"[fast-molecule] Changed roles: {roles}": {
"bg": "[fast-molecule] Changed roles: {roles}",
"de": "[fast-molecule] Changed roles: {roles}",
"en": "[fast-molecule] Changed roles: {roles}",
"pl": "[fast-molecule] Changed roles: {roles}",
"ru": "[fast-molecule] Changed roles: {roles}",
"zh": "[fast-molecule] Changed roles: {roles}"
},
"[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.": {
"bg": "[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.",
"de": "[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.",
"en": "[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.",
"pl": "[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.",
"ru": "[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes.",
"zh": "[nightly-gate] Nightly FAILED{run}. Staging deploys are blocked until nightly passes."
},
"[nightly-gate] Set NIGHTLY_STATUS=failed{run}": {
"bg": "[nightly-gate] Set NIGHTLY_STATUS=failed{run}",
"de": "[nightly-gate] Set NIGHTLY_STATUS=failed{run}",
"en": "[nightly-gate] Set NIGHTLY_STATUS=failed{run}",
"pl": "[nightly-gate] Set NIGHTLY_STATUS=failed{run}",
"ru": "[nightly-gate] Set NIGHTLY_STATUS=failed{run}",
"zh": "[nightly-gate] Set NIGHTLY_STATUS=failed{run}"
},
"[nightly-gate] Set NIGHTLY_STATUS=passed{run}": {
"bg": "[nightly-gate] Set NIGHTLY_STATUS=passed{run}",
"de": "[nightly-gate] Set NIGHTLY_STATUS=passed{run}",
"en": "[nightly-gate] Set NIGHTLY_STATUS=passed{run}",
"pl": "[nightly-gate] Set NIGHTLY_STATUS=passed{run}",
"ru": "[nightly-gate] Set NIGHTLY_STATUS=passed{run}",
"zh": "[nightly-gate] Set NIGHTLY_STATUS=passed{run}"
},
"[spec-check] Spec validated: {path}": {
"bg": "[spec-check] Spec validated: {path}",
"de": "[spec-check] Spec validated: {path}",
"en": "[spec-check] Spec validated: {path}",
"pl": "[spec-check] Spec validated: {path}",
"ru": "[spec-check] Spec validated: {path}",
"zh": "[spec-check] Spec validated: {path}"
},
"[tofu-init] Done.": {
"bg": "[tofu-init] Готово.",
"de": "[tofu-init] Fertig.",
@@ -3455,6 +3911,14 @@
"ru": "git command failed ({cmd}): {stderr}",
"zh": "git command failed ({cmd}): {stderr}"
},
"git diff --numstat failed: {stderr}": {
"bg": "git diff --numstat failed: {stderr}",
"de": "git diff --numstat failed: {stderr}",
"en": "git diff --numstat failed: {stderr}",
"pl": "git diff --numstat failed: {stderr}",
"ru": "git diff --numstat failed: {stderr}",
"zh": "git diff --numstat failed: {stderr}"
},
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.": {
"bg": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
"de": "git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
@@ -3479,6 +3943,14 @@
"ru": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).",
"zh": "git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1)."
},
"importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.": {
"bg": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"de": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"en": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
},
"in_progress": {
"bg": "в процес",
"de": "in Bearbeitung",
@@ -3630,173 +4102,5 @@
"pl": "{separator}",
"ru": "{separator}",
"zh": "{separator}"
},
"\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n": {
"bg": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"de": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"en": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"pl": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"ru": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n",
"zh": "\nTest isolation check FAILED: {count} violation(s) in {files} file(s).\n"
},
" Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'": {
"bg": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"de": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"en": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"pl": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"ru": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'",
"zh": " Fix the PR title with:\n python3 -m devx.ci.fix_pr_title --repo {repo} --pr-number {pr}\n Or manually set the PR title to: '{expected}'"
},
"Add @patch(\"subprocess.run\") or patch the calling function to fix this.": {
"bg": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"de": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"en": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"pl": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"ru": "Add @patch(\"subprocess.run\") or patch the calling function to fix this.",
"zh": "Add @patch(\"subprocess.run\") or patch the calling function to fix this."
},
"Branch name (auto-fetched from PR if not given)": {
"bg": "Branch name (auto-fetched from PR if not given)",
"de": "Branch name (auto-fetched from PR if not given)",
"en": "Branch name (auto-fetched from PR if not given)",
"pl": "Branch name (auto-fetched from PR if not given)",
"ru": "Branch name (auto-fetched from PR if not given)",
"zh": "Branch name (auto-fetched from PR if not given)"
},
"CI_GITEA_API_TOKEN not set: {error}": {
"bg": "CI_GITEA_API_TOKEN not set: {error}",
"de": "CI_GITEA_API_TOKEN not set: {error}",
"en": "CI_GITEA_API_TOKEN not set: {error}",
"pl": "CI_GITEA_API_TOKEN not set: {error}",
"ru": "CI_GITEA_API_TOKEN not set: {error}",
"zh": "CI_GITEA_API_TOKEN not set: {error}"
},
"CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.": {
"bg": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"de": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"en": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"pl": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"ru": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function.",
"zh": "CliRunner.invoke({target}) in test '{test}' reaches unpatched dangerous functions: {funcs}. Add @patch for each or patch the calling function."
},
"Could not determine branch name from PR #{pr}": {
"bg": "Could not determine branch name from PR #{pr}",
"de": "Could not determine branch name from PR #{pr}",
"en": "Could not determine branch name from PR #{pr}",
"pl": "Could not determine branch name from PR #{pr}",
"ru": "Could not determine branch name from PR #{pr}",
"zh": "Could not determine branch name from PR #{pr}"
},
"Failed to fetch PR #{pr}: {error}": {
"bg": "Failed to fetch PR #{pr}: {error}",
"de": "Failed to fetch PR #{pr}: {error}",
"en": "Failed to fetch PR #{pr}: {error}",
"pl": "Failed to fetch PR #{pr}: {error}",
"ru": "Failed to fetch PR #{pr}: {error}",
"zh": "Failed to fetch PR #{pr}: {error}"
},
"Failed to update PR #{pr}: {error}": {
"bg": "Failed to update PR #{pr}: {error}",
"de": "Failed to update PR #{pr}: {error}",
"en": "Failed to update PR #{pr}: {error}",
"pl": "Failed to update PR #{pr}: {error}",
"ru": "Failed to update PR #{pr}: {error}",
"zh": "Failed to update PR #{pr}: {error}"
},
"Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.": {
"bg": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"de": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"en": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"pl": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"ru": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.",
"zh": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function."
},
"Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n": {
"bg": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"de": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"en": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"pl": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"ru": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n",
"zh": "Fix: add @patch decorators or with patch() context managers for subprocess/time.sleep calls, or patch the calling function.\n"
},
"Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.": {
"bg": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"de": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"en": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"pl": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"ru": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import.",
"zh": "Heavy import '{mod}' (~{ms:.0f}ms) at module level — this slows test collection for all tests. Move inside test functions or use lazy import."
},
"No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch '{branch}'. Expected format: {prefix}-N-description."
},
"PR number to fix": {
"bg": "PR number to fix",
"de": "PR number to fix",
"en": "PR number to fix",
"pl": "PR number to fix",
"ru": "PR number to fix",
"zh": "PR number to fix"
},
"Real subprocess call(s) detected in test '{test}' without @patch:": {
"bg": "Real subprocess call(s) detected in test '{test}' without @patch:",
"de": "Real subprocess call(s) detected in test '{test}' without @patch:",
"en": "Real subprocess call(s) detected in test '{test}' without @patch:",
"pl": "Real subprocess call(s) detected in test '{test}' without @patch:",
"ru": "Real subprocess call(s) detected in test '{test}' without @patch:",
"zh": "Real subprocess call(s) detected in test '{test}' without @patch:"
},
"Show what would change without updating": {
"bg": "Show what would change without updating",
"de": "Show what would change without updating",
"en": "Show what would change without updating",
"pl": "Show what would change without updating",
"ru": "Show what would change without updating",
"zh": "Show what would change without updating"
},
"Test isolation check FAILED: {count} violation(s) in {files} file(s).": {
"bg": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"de": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"en": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"pl": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"ru": "Test isolation check FAILED: {count} violation(s) in {files} file(s).",
"zh": "Test isolation check FAILED: {count} violation(s) in {files} file(s)."
},
"Test isolation check passed with {count} advisory warning(s) in {files} file(s).": {
"bg": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"de": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"en": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"pl": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"ru": "Test isolation check passed with {count} advisory warning(s) in {files} file(s).",
"zh": "Test isolation check passed with {count} advisory warning(s) in {files} file(s)."
},
"Transitive-subprocess advisories (runtime audit is authoritative):": {
"bg": "Transitive-subprocess advisories (runtime audit is authoritative):",
"de": "Transitive-subprocess advisories (runtime audit is authoritative):",
"en": "Transitive-subprocess advisories (runtime audit is authoritative):",
"pl": "Transitive-subprocess advisories (runtime audit is authoritative):",
"ru": "Transitive-subprocess advisories (runtime audit is authoritative):",
"zh": "Transitive-subprocess advisories (runtime audit is authoritative):"
},
"importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.": {
"bg": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"de": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"en": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
},
"[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)": {
"en": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"bg": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"de": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
}
}
+132
View File
@@ -0,0 +1,132 @@
"""Unit tests for devx.ci.check_pr_size."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.check_pr_size import (
check_size,
cli,
get_diff_stats,
is_excluded,
)
class TestIsExcluded:
def test_excludes_changelog(self) -> None:
assert is_excluded("CHANGELOG.md", ["CHANGELOG.md"])
def test_excludes_svg_glob(self) -> None:
assert is_excluded("docs/badges/coverage.svg", ["*.svg"])
def test_does_not_exclude_source(self) -> None:
assert not is_excluded("src/devx/ci/check_pr_size.py", ["CHANGELOG.md", "*.svg"])
def test_excludes_readme(self) -> None:
assert is_excluded("README.md", ["README.md"])
class TestCheckSize:
def test_under_limits_passes(self) -> None:
stats = [("src/main.py", 100, 50), ("tests/test_main.py", 80, 20)]
ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=[])
assert ok is True
assert "250" in detail # 100+50+80+20
def test_over_lines_fails(self) -> None:
stats = [("src/main.py", 300, 300)]
ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=[])
assert ok is False
assert "600" in detail
def test_over_files_fails(self) -> None:
stats = [(f"src/file{i}.py", 10, 5) for i in range(15)]
ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=[])
assert ok is False
assert "15" in detail
def test_excluded_files_not_counted(self) -> None:
stats = [("CHANGELOG.md", 500, 500), ("src/main.py", 10, 5)]
ok, detail = check_size(stats, max_lines=500, max_files=10, excluded_patterns=["CHANGELOG.md"])
assert ok is True
assert "15" in detail # only 10+5
def test_empty_stats_passes(self) -> None:
ok, detail = check_size([], max_lines=500, max_files=10, excluded_patterns=[])
assert ok is True
class TestGetDiffStats:
@patch("devx.ci.check_pr_size.subprocess.run")
def test_parses_numstat_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="10\t5\tsrc/main.py\n20\t10\ttests/test_main.py\n",
stderr="",
)
stats = get_diff_stats("origin/master", "HEAD")
assert len(stats) == 2
assert stats[0] == ("src/main.py", 10, 5)
assert stats[1] == ("tests/test_main.py", 20, 10)
@patch("devx.ci.check_pr_size.subprocess.run")
def test_handles_binary_files(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="-\t-\timage.png\n",
stderr="",
)
stats = get_diff_stats("origin/master", "HEAD")
assert len(stats) == 1
assert stats[0] == ("image.png", 0, 0)
@patch("devx.ci.check_pr_size.subprocess.run")
def test_empty_output(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
stats = get_diff_stats("origin/master", "HEAD")
assert stats == []
@patch("devx.ci.check_pr_size.subprocess.run")
def test_git_diff_failure_raises(self, mock_run: MagicMock) -> None:
import pytest
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="fatal: bad ref")
with pytest.raises(Exception, match="git diff|bad ref"):
get_diff_stats("origin/master", "HEAD")
@patch("devx.ci.check_pr_size.subprocess.run")
def test_malformed_line_skipped(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="not_a_valid_line\n10\t5\tsrc/main.py\n",
stderr="",
)
stats = get_diff_stats("origin/master", "HEAD")
assert len(stats) == 1
assert stats[0] == ("src/main.py", 10, 5)
class TestCli:
@patch("devx.ci.check_pr_size.subprocess.run")
def test_passes_when_small(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="10\t5\tsrc/main.py\n",
stderr="",
)
runner = CliRunner()
result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD"])
assert result.exit_code == 0
assert "OK" in result.output
@patch("devx.ci.check_pr_size.subprocess.run")
def test_fails_when_too_large(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(
returncode=0,
stdout="300\t300\tsrc/main.py\n",
stderr="",
)
runner = CliRunner()
result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD", "--max-lines", "500"])
assert result.exit_code != 0
assert "600" in result.output
-7
View File
@@ -107,13 +107,6 @@ class TestCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.post_merge", ["DEVX-1"])
@patch("devx.cli._run_module")
def test_ci_pr_review(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "pr-review", "42"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.pr_review", ["42"])
@patch("devx.cli._run_module")
def test_ci_publish(self, mock_run: MagicMock) -> None:
runner = CliRunner()
+182
View File
@@ -0,0 +1,182 @@
"""Unit tests for devx.ci.create_dependency_pr."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
from click.testing import CliRunner
from devx.ci.create_dependency_pr import (
cli,
create_vikunja_task,
find_existing_pr,
find_pinned_version,
update_pinned_version,
)
class TestFindPinnedVersion:
def test_finds_pip_git_pin(self, tmp_path: Path) -> None:
content = "grm @ git+https://git.example.com/repo.git@v0.5.1"
path = tmp_path / "pyproject.toml"
path.write_text(content)
version = find_pinned_version("grm", str(path))
assert version == "0.5.1"
def test_finds_pyproject_pin(self, tmp_path: Path) -> None:
content = 'grm = "0.5.1"'
path = tmp_path / "pyproject.toml"
path.write_text(content)
version = find_pinned_version("grm", str(path))
assert version == "0.5.1"
def test_finds_ansible_var_pin(self, tmp_path: Path) -> None:
content = 'grm_version: "0.5.1"'
path = tmp_path / "images.yml"
path.write_text(content)
version = find_pinned_version("grm", str(path))
assert version == "0.5.1"
def test_finds_image_version_pin(self, tmp_path: Path) -> None:
content = 'sso_bridge_image_version: "1.2.3"'
path = tmp_path / "images.yml"
path.write_text(content)
version = find_pinned_version("sso_bridge", str(path))
assert version == "1.2.3"
def test_returns_none_when_not_found(self, tmp_path: Path) -> None:
path = tmp_path / "pyproject.toml"
path.write_text('other = "1.0.0"')
assert find_pinned_version("grm", str(path)) is None
def test_returns_none_when_file_missing(self, tmp_path: Path) -> None:
assert find_pinned_version("grm", str(tmp_path / "nonexistent.toml")) is None
class TestUpdatePinnedVersion:
def test_updates_pip_git_pin(self, tmp_path: Path) -> None:
content = "grm @ git+https://git.example.com/repo.git@v0.5.1"
path = tmp_path / "pyproject.toml"
path.write_text(content)
changed = update_pinned_version(str(path), "grm", "0.5.1", "0.5.2")
assert changed is True
assert "0.5.2" in path.read_text()
assert "0.5.1" not in path.read_text()
def test_updates_pyproject_pin(self, tmp_path: Path) -> None:
content = 'grm = "0.5.1"'
path = tmp_path / "pyproject.toml"
path.write_text(content)
changed = update_pinned_version(str(path), "grm", "0.5.1", "0.5.2")
assert changed is True
assert 'grm = "0.5.2"' in path.read_text()
def test_no_change_when_version_not_found(self, tmp_path: Path) -> None:
content = 'other = "1.0.0"'
path = tmp_path / "pyproject.toml"
path.write_text(content)
changed = update_pinned_version(str(path), "grm", "0.5.1", "0.5.2")
assert changed is False
def test_no_change_when_file_missing(self, tmp_path: Path) -> None:
changed = update_pinned_version(str(tmp_path / "nonexistent"), "grm", "0.5.1", "0.5.2")
assert changed is False
class TestFindExistingPr:
@patch("devx.tools.create_pr.GiteaClient")
def test_returns_pr_when_found(self, mock_client_cls: MagicMock) -> None:
mock_client = mock_client_cls.return_value
mock_client.list_prs.return_value = [
{"head": {"ref": "deps/grm-0.5.2"}, "number": 42},
{"head": {"ref": "other-branch"}, "number": 43},
]
result = find_existing_pr(mock_client, "deps/grm-0.5.2")
assert result is not None
assert result["number"] == 42
@patch("devx.tools.create_pr.GiteaClient")
def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = mock_client_cls.return_value
mock_client.list_prs.return_value = []
result = find_existing_pr(mock_client, "deps/grm-0.5.2")
assert result is None
class TestCli:
@patch("devx.ci.create_dependency_pr.find_pinned_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_same_version_no_pr(self, mock_token: MagicMock, mock_find: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_find.return_value = "0.5.2"
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"grm",
"--new-version",
"0.5.2",
"--source-repo",
"oblachno/grm",
],
)
assert result.exit_code == 0
assert "no pr needed" in result.output.lower()
@patch("devx.ci.create_dependency_pr.find_pinned_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_dry_run(self, mock_token: MagicMock, mock_find: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_find.return_value = "0.5.1"
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"grm",
"--new-version",
"0.5.2",
"--source-repo",
"oblachno/grm",
"--dry-run",
],
)
assert result.exit_code == 0
assert "DRY RUN" in result.output
@patch("devx.ci.create_dependency_pr.find_pinned_version")
@patch("devx.ci.create_dependency_pr.get_ci_token")
def test_version_not_found_fails(self, mock_token: MagicMock, mock_find: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_find.return_value = None
runner = CliRunner()
result = runner.invoke(
cli,
[
"--package",
"nonexistent",
"--new-version",
"1.0.0",
"--source-repo",
"oblachno/test",
],
)
assert result.exit_code != 0
class TestCreateVikunjaTask:
def test_returns_none_when_no_token(self) -> None:
with patch("devx.ci.create_dependency_pr.get_vikunja_token", side_effect=click.ClickException("no token")):
result = create_vikunja_task("Test", "desc")
assert result is None
def test_returns_identifier_on_success(self) -> None:
with (
patch("devx.ci.create_dependency_pr.get_vikunja_token", return_value="fake-token"),
patch("devx.api_clients.VikunjaClient") as mock_client_cls,
):
mock_client = mock_client_cls.return_value
mock_client.create_task.return_value = {"identifier": "OBL-INFRA-999"}
result = create_vikunja_task("Test", "desc")
assert result == "OBL-INFRA-999"
+82
View File
@@ -0,0 +1,82 @@
"""Unit tests for devx.ci.fast_molecule."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.fast_molecule import (
build_molecule_commands,
cli,
get_molecule_scenarios,
)
class TestGetMoleculeScenarios:
def test_finds_scenarios(self, tmp_path: Path) -> None:
roles_dir = tmp_path / "ansible" / "roles" / "myrole" / "molecule"
roles_dir.mkdir(parents=True)
(roles_dir / "default").mkdir()
(roles_dir / "default" / "molecule.yml").write_text("name: default")
(roles_dir / "full").mkdir()
(roles_dir / "full" / "molecule.yml").write_text("name: full")
(roles_dir / "no_scenario").mkdir() # No molecule.yml
scenarios = get_molecule_scenarios("myrole", str(tmp_path / "ansible" / "roles"))
assert sorted(scenarios) == ["default", "full"]
def test_returns_empty_when_no_molecule_dir(self, tmp_path: Path) -> None:
scenarios = get_molecule_scenarios("nonexistent", str(tmp_path / "ansible" / "roles"))
assert scenarios == []
class TestBuildMoleculeCommands:
def test_builds_commands_for_roles(self, tmp_path: Path) -> None:
roles_dir = tmp_path / "ansible" / "roles"
for role in ["role_a", "role_b"]:
mol_dir = roles_dir / role / "molecule" / "default"
mol_dir.mkdir(parents=True)
(mol_dir / "molecule.yml").write_text("name: default")
commands = build_molecule_commands({"role_a", "role_b"}, str(roles_dir))
assert len(commands) == 2
assert all("molecule test -s default" in c for c in commands)
assert all("--destroy=never" in c for c in commands)
assert all("ubuntu-2604" in c for c in commands)
def test_empty_when_no_scenarios(self, tmp_path: Path) -> None:
commands = build_molecule_commands({"nonexistent"}, str(tmp_path / "ansible" / "roles"))
assert commands == []
def test_empty_when_no_roles(self) -> None:
assert build_molecule_commands(set()) == []
class TestCli:
@patch("devx.ci.fast_molecule.get_changed_files")
def test_no_changes(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
runner = CliRunner()
result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD"])
assert result.exit_code == 0
assert "No files changed" in result.output
@patch("devx.ci.fast_molecule.detect_changed_roles")
@patch("devx.ci.fast_molecule.get_changed_files")
def test_no_ansible_changes(self, mock_get: MagicMock, mock_detect: MagicMock) -> None:
mock_get.return_value = ["src/main.py", "README.md"]
mock_detect.return_value = set()
runner = CliRunner()
result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD"])
assert result.exit_code == 0
assert "No Ansible roles changed" in result.output
@patch("devx.ci.fast_molecule.detect_changed_roles")
@patch("devx.ci.fast_molecule.get_changed_files")
def test_detects_changed_roles(self, mock_get: MagicMock, mock_detect: MagicMock) -> None:
mock_get.return_value = ["ansible/roles/sso_bridge/tasks/main.yml"]
mock_detect.return_value = {"sso_bridge"}
runner = CliRunner()
result = runner.invoke(cli, ["--base", "origin/master", "--head", "HEAD"])
assert result.exit_code == 0
assert "sso_bridge" in result.output
+106
View File
@@ -0,0 +1,106 @@
"""Unit tests for devx.ci.nightly_gate."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.nightly_gate import cli, get_nightly_status, set_nightly_status
class TestGetNightlyStatus:
@patch("devx.ci.nightly_gate.GiteaClient")
def test_returns_value_when_set(self, mock_client_cls: MagicMock) -> None:
mock_client = mock_client_cls.return_value
mock_client.get_repo_variable.return_value = "passed:12345"
result = get_nightly_status(mock_client)
assert result == "passed:12345"
@patch("devx.ci.nightly_gate.GiteaClient")
def test_returns_empty_when_not_set(self, mock_client_cls: MagicMock) -> None:
mock_client = mock_client_cls.return_value
mock_client.get_repo_variable.return_value = None
result = get_nightly_status(mock_client)
assert result == ""
class TestSetNightlyStatus:
@patch("devx.ci.nightly_gate.GiteaClient")
def test_sets_passed(self, mock_client_cls: MagicMock) -> None:
mock_client = mock_client_cls.return_value
set_nightly_status(mock_client, "passed:12345")
mock_client.set_repo_variable.assert_called_once_with("NIGHTLY_STATUS", "passed:12345")
@patch("devx.ci.nightly_gate.GiteaClient")
def test_sets_failed(self, mock_client_cls: MagicMock) -> None:
mock_client = mock_client_cls.return_value
set_nightly_status(mock_client, "failed:99999")
mock_client.set_repo_variable.assert_called_once_with("NIGHTLY_STATUS", "failed:99999")
class TestCli:
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_check_bootstrap_allows_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 = None
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "check"])
assert result.exit_code == 0
assert "bootstrap" in result.output.lower()
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_check_passed_allows_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 = "passed:12345"
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "check"])
assert result.exit_code == 0
assert "passed" in result.output.lower()
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_check_failed_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 = "failed:99999"
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "check"])
assert result.exit_code != 0
assert "blocked" in result.output.lower()
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_set_passed(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_client = mock_client_cls.return_value
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "set-passed", "--run-id", "12345"])
assert result.exit_code == 0
mock_client.set_repo_variable.assert_called_once_with("NIGHTLY_STATUS", "passed:12345")
@patch("devx.ci.nightly_gate.GiteaClient")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_set_failed(self, mock_token: MagicMock, mock_client_cls: MagicMock) -> None:
mock_token.return_value = "fake-token"
mock_client = mock_client_cls.return_value
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "set-failed", "--run-id", "99999"])
assert result.exit_code == 0
mock_client.set_repo_variable.assert_called_once_with("NIGHTLY_STATUS", "failed:99999")
@patch("devx.ci.nightly_gate.get_ci_token")
def test_fails_without_token(self, mock_token: MagicMock) -> None:
import click as click_mod
mock_token.side_effect = click_mod.ClickException("No token")
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "oblachno/infra", "--action", "check"])
assert result.exit_code != 0
def test_fails_with_invalid_repo(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--repo", "invalid", "--action", "check"])
assert result.exit_code != 0
-1022
View File
@@ -1,1022 +0,0 @@
"""Unit tests for scripts/ci/pr_review.py."""
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.ci.pr_review import (
ReviewResult,
build_review_body,
check_architecture_compliance,
check_best_practices,
check_commit_conventions,
check_documentation,
check_function_length,
check_i18n,
check_resource_management,
check_security,
check_test_coverage,
is_python_file,
is_workflow_only,
main,
post_review,
run_review,
)
from devx.exceptions import APIError
class TestIsPythonFile:
def test_python_file_in_src(self) -> None:
assert is_python_file("src/devx/cli.py") is True
def test_python_file_in_scripts(self) -> None:
assert is_python_file("scripts/ci/release.py") is True
def test_test_file_excluded(self) -> None:
assert is_python_file("tests/unit/test_cli.py") is False
def test_non_python_file(self) -> None:
assert is_python_file("README.md") is False
def test_yaml_file(self) -> None:
assert is_python_file(".gitea/workflows/ci.yml") is False
class TestIsWorkflowOnly:
def test_yaml_is_workflow(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
def test_md_is_workflow(self) -> None:
assert is_workflow_only("README.md") is True
def test_python_is_not_workflow(self) -> None:
assert is_workflow_only("src/devx/cli.py") is False
def test_ansible_is_workflow(self) -> None:
assert is_workflow_only("ansible/tasks/main.yml") is True
class TestReviewResult:
def test_empty_result_has_no_issues(self) -> None:
result = ReviewResult()
assert result.has_issues is False
def test_add_issue_makes_has_issues_true(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
assert result.has_issues is True
assert len(result.issues) == 1
assert result.issues[0]["path"] == "src/foo.py"
assert result.issues[0]["new_position"] == 10
def test_add_summary(self) -> None:
result = ReviewResult()
result.add_summary("all good")
assert "all good" in result.summary
class TestCheckArchitectureCompliance:
def test_subprocess_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "subprocess" in result.issues[0]["body"].lower()
def test_subprocess_in_other_file_ok(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_no_changes_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert any("Architecture compliance: OK" in s for s in result.summary)
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_os_system_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
def test_bare_except_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/runner_manager.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "bare except" in result.issues[0]["body"]
def test_todo_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "TODO" in result.issues[0]["body"]
def test_clean_code_no_issues(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
}
]
check_best_practices(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_best_practices(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
check_best_practices(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -1,2 @@\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/config.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
def test_example_token_not_flagged(self) -> None:
result = ReviewResult()
files = [
{
"filename": ".env.example",
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
}
]
check_security(files, result)
assert not result.has_issues
def test_shell_true_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "shell=True" in result.issues[0]["body"]
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/config.py", "patch": ""}]
check_security(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
check_security(files, result)
assert not result.has_issues
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [
{
"filename": "src/devx/config.py",
"patch": "@@ -1,2 @@\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
class TestCheckI18n:
def test_raw_string_in_echo_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
def test_translated_string_no_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_fstring_in_echo_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(f"Hello {name}")\n'}]
check_i18n(files, result)
assert result.has_issues
def test_raw_exception_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+raise click.ClickException("Something went wrong")\n',
}
]
check_i18n(files, result)
assert result.has_issues
def test_non_src_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "scripts/ci/test.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_comment_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# click.echo("Hello world")\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_i18n(files, result)
assert not result.has_issues
def test_clean_code_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}]
check_i18n(files, result)
assert any("i18n: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
class TestCheckResourceManagement:
def test_open_without_with_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
def test_open_with_with_no_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ pass\n',
}
]
check_resource_management(files, result)
assert not result.has_issues
def test_popen_without_cleanup_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": '@@ -1,1 +1,1 @@\n+proc = subprocess.Popen(["cmd"])\n',
}
]
check_resource_management(files, result)
assert result.has_issues
def test_popen_with_communicate_no_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/executor.py",
"patch": '@@ -1,1 +1,1 @@\n+out, err = subprocess.Popen(["cmd"], stdout=PIPE).communicate()\n',
}
]
check_resource_management(files, result)
assert not result.has_issues
def test_comment_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# f = open("file.txt")\n'}]
check_resource_management(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_resource_management(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert not result.has_issues
def test_clean_code_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/devx/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ data = f.read()\n',
}
]
check_resource_management(files, result)
assert any("Resource management: OK" in s for s in result.summary)
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": '@@ -1,2 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
result = ReviewResult()
# Create a patch with a function that adds > 50 lines
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_short_function_no_warning(self) -> None:
result = ReviewResult()
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": ""}]
check_function_length(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "README.md", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_multiple_functions_resets_count(self) -> None:
"""Two short functions back-to-back should not trigger the length warning."""
result = ReviewResult()
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_long_function_followed_by_new_hunk(self) -> None:
"""Long function followed by @@ header triggers the warning at hunk boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = (
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
)
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_long_function_followed_by_new_def(self) -> None:
"""Long function followed by another def triggers the warning at def boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
files = [{"filename": "src/devx/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_malformed_hunk_header_no_line_number(self) -> None:
"""A @@ header without a +N line number is handled gracefully."""
result = ReviewResult()
files = [{"filename": "src/devx/cli.py", "patch": "@@ -1,2 @@\n+def foo():\n+ pass\n"}]
check_function_length(files, result)
assert not result.has_issues
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_docs_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
def test_ansible_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_only_doc_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
def test_tofu_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "tofu/modules/hetzner-vm/main.tf"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_workflow_changes_info(self) -> None:
result = ReviewResult()
files = [{"filename": ".gitea/workflows/ci.yml"}]
check_documentation(files, result)
assert any("INFO" in s for s in result.summary)
def test_todo_in_doc_patch_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/guide.md", "patch": "+TODO: fix this later\n+Some content\n"}]
check_documentation(files, result)
assert any("TODO" in s for s in result.summary)
def test_todo_in_readme_patch_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "+FIXME: broken\n"}]
check_documentation(files, result)
assert any("FIXME" in s for s in result.summary)
def test_no_todo_in_doc_patch_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/guide.md", "patch": "+Some content\n"}]
check_documentation(files, result)
assert not any("TODO" in s for s in result.summary)
class TestCheckTestCoverage:
def test_src_changes_without_tests_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}]
check_test_coverage(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_tests_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/devx/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
def test_only_test_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
class TestBuildReviewBody:
def test_body_contains_summary(self) -> None:
result = ReviewResult()
result.add_summary("- Architecture compliance: OK")
body = build_review_body(result)
assert "Architecture compliance: OK" in body
assert "Automated PR Review" in body
def test_body_contains_issues(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
body = build_review_body(result)
assert "1 issue(s) found" in body
assert "src/foo.py:10" in body
assert "bad code" in body
def test_body_contains_no_issues_message(self) -> None:
result = ReviewResult()
body = build_review_body(result)
assert "No issues found" in body
def test_body_contains_auto_merge_note(self) -> None:
"""Review body must mention auto-merge."""
result = ReviewResult()
body = build_review_body(result)
assert "Auto-merge" in body
class TestRunReview:
@patch("devx.ci.pr_review.GiteaClient")
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = []
result = run_review(mock_client, "42")
assert "No files changed" in result.summary[0]
@patch("devx.ci.pr_review.GiteaClient")
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = [
{
"filename": "src/devx/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
result = run_review(mock_client, "42")
assert result.has_issues
def test_run_review_handles_api_error(self) -> None:
client = MagicMock()
client.get_pr_files.side_effect = APIError(404, "Not found")
result = run_review(client, "42")
assert any("ERROR" in s for s in result.summary)
class TestCheckCommitConventions:
def test_conventional_commit_found(self) -> None:
"""Should report OK when at least one commit is conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_no_conventional_commit(self) -> None:
"""Should warn when no commits are conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "updated stuff"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("WARNING" in s for s in result.summary)
def test_merge_commits_excluded(self) -> None:
"""Merge commits should be excluded from the check."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "fix: resolve bug"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_all_merges_and_reverts(self) -> None:
"""Should report OK when all commits are merges/reverts."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "Revert: bad commit"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("merges/reverts" in s for s in result.summary)
def test_no_commits(self) -> None:
"""Should report OK when there are no commits."""
client = MagicMock()
client.get_pr_commits.return_value = []
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("no commits" in s for s in result.summary)
def test_api_error(self) -> None:
"""Should report ERROR when API call fails."""
client = MagicMock()
client.get_pr_commits.side_effect = APIError(500, "server error")
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("ERROR" in s for s in result.summary)
class TestPostReview:
def test_post_review_with_issues(self) -> None:
client = MagicMock()
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "REQUEST_CHANGES"
assert call_args[1]["comments"] == result.issues
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
client = MagicMock()
result = ReviewResult()
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "COMMENT"
assert call_args[1]["comments"] == []
class TestMain:
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo", "--dry-run"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once()
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 124},
]
runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #124" in result.output
assert client.create_review.call_count == 2
@patch("devx.ci.pr_review.run_review")
@patch("devx.ci.pr_review.GiteaClient")
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""Non-approval API errors should re-raise, not fall back."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0
@patch.dict("os.environ", {"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
class TestManualReview:
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_success(self, mock_client_class: MagicMock) -> None:
mock_client_class.return_value.create_review.return_value = {"id": 200}
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"All 13 REVIEW_CHECKLIST.md categories verified. Architecture: clean. Security: no issues.",
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8,9,10,11,12,13",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code == 0
assert "Review #200" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_without_checklist_confirmed_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "checklist-confirmed" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_with_too_few_categories_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "at least 8" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_with_short_body_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"LGTM",
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "50 characters" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_approve_with_invalid_category_fails(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,abc,4",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
assert "Invalid" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_request_changes_success(self, mock_client_class: MagicMock) -> None:
mock_client_class.return_value.create_review.return_value = {"id": 201}
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"REQUEST_CHANGES",
"--body",
"Please fix the architecture issues in the CLI module before merging.",
],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code == 0
assert "Review #201" in result.output
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_dry_run(self, mock_client_class: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60, "--dry-run"],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_fallback_to_comment(
self, mock_client_class: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Self-approval with no CI token available → fall back to COMMENT."""
monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False)
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 202},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer"},
)
assert result.exit_code == 0
assert "Review #202" in result.output
# Without CI_GITEA_API_TOKEN, the fallback is COMMENT
assert "Self-approval not allowed. Posting COMMENT instead." in result.output
assert client.create_review.call_count == 2
assert client.create_review.call_args_list[1].kwargs.get("event") == "COMMENT"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_self_approval_falls_back_to_ci_token(self, mock_client_class: MagicMock) -> None:
"""Self-approval with CI token available → retry APPROVE with CI token (different user)."""
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 303},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer", "CI_GITEA_API_TOKEN": "fake-ci"},
)
assert result.exit_code == 0
assert "Review #303" in result.output
assert "Retrying with CI token" in result.output
# Second call should still be APPROVE (CI token retry)
assert client.create_review.call_count == 2
assert client.create_review.call_args_list[1].kwargs.get("event") == "APPROVE"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_ci_token_also_fails_falls_back_to_comment(self, mock_client_class: MagicMock) -> None:
"""Self-approval + CI token retry also fails → fall back to COMMENT."""
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
APIError(422, "approve your own pull is not allowed"),
{"id": 404},
]
runner = CliRunner()
result = runner.invoke(
main,
[
"42",
"oblachno-oss/devx",
"--event",
"APPROVE",
"--body",
"x" * 60,
"--checklist-confirmed",
"--checklist-categories",
"1,2,3,4,5,6,7,8",
],
env={"REVIEWER_GITEA_API_TOKEN": "fake-reviewer", "CI_GITEA_API_TOKEN": "fake-ci"},
)
assert result.exit_code == 0
assert "Review #404" in result.output
assert "CI token also cannot approve" in result.output
# Third call should be COMMENT (final fallback)
assert client.create_review.call_count == 3
assert client.create_review.call_args_list[2].kwargs.get("event") == "COMMENT"
@patch("devx.ci.pr_review.GiteaClient")
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None:
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(
main,
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60],
env={"CI_GITEA_TOKEN": "fake"},
)
assert result.exit_code != 0
def test_main_module_block() -> None:
import devx.ci.pr_review as pr
with patch.object(pr, "main") as mock_main:
with patch.object(pr, "__name__", "__main__"):
pr.main([])
mock_main.assert_called_once_with([])
+784
View File
@@ -0,0 +1,784 @@
"""Structural tests for spec-driven development workflows and skills.
These tests parse the actual workflow YAML files in each repo and assert
that the new spec-driven development steps, jobs, and env vars are present
and correctly wired. They also validate that the spec-driven-development
skill exists in each repo's .devin/skills/ directory with required sections.
This is a "contract test" it verifies that the workflows we wrote match
the intended structure, catching regressions if someone edits a workflow
and accidentally removes a step or breaks a job dependency.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
# Repo root paths
# __file__ = .../devx/tests/unit/test_spec_driven_workflows.py
# parents[3] = .../oblachno (the monorepo root containing all repos)
_OBLACHNO_ROOT = Path(__file__).resolve().parents[3]
_INFRA = _OBLACHNO_ROOT / "infra"
_GRM = _OBLACHNO_ROOT / "grm"
_SSO_BRIDGE = _OBLACHNO_ROOT / "sso-bridge"
_DEVX = _OBLACHNO_ROOT / "devx"
def _load_workflow(repo_path: Path, filename: str) -> dict:
"""Load a workflow YAML file and return parsed dict."""
path = repo_path / ".gitea" / "workflows" / filename
if not path.exists():
pytest.skip(f"Workflow {filename} not found in {repo_path.name}")
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
def _get_step_names(job: dict) -> list[str]:
"""Extract step names from a job dict."""
names = []
for step in job.get("steps", []):
if "name" in step:
names.append(step["name"])
return names
def _find_step(job: dict, name_part: str) -> dict | None:
"""Find a step by partial name match."""
for step in job.get("steps", []):
if "name" in step and name_part.lower() in step["name"].lower():
return step
return None
def _get_run_commands(step: dict) -> str:
"""Get the run command from a step."""
return step.get("run", "")
# ============================================================================
# Infra ci.yml — spec validation + PR size + fast molecule
# ============================================================================
class TestInfraCiWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_INFRA, "ci.yml")
def test_validate_job_exists(self, workflow: dict) -> None:
assert "validate" in workflow["jobs"]
def test_has_spec_validation_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Validate spec file" in s for s in steps), "validate job must have 'Validate spec file' step"
def test_has_pr_size_check_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Check PR size" in s for s in steps), "validate job must have 'Check PR size' step"
def test_spec_validation_uses_correct_module(self, workflow: dict) -> None:
step = _find_step(workflow["jobs"]["validate"], "Validate spec file")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.validate_spec" in cmd
assert "--github-output" in cmd
def test_pr_size_uses_correct_module(self, workflow: dict) -> None:
step = _find_step(workflow["jobs"]["validate"], "Check PR size")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.check_pr_size" in cmd
assert "--github-output" in cmd
def test_spec_validation_sets_task_prefix(self, workflow: dict) -> None:
step = _find_step(workflow["jobs"]["validate"], "Validate spec file")
assert step is not None
env = step.get("env", {})
assert env.get("DEVX_TASK_PREFIX") == "OBL-INFRA"
def test_has_fast_molecule_job(self, workflow: dict) -> None:
assert "fast-molecule" in workflow["jobs"], "ci.yml must have 'fast-molecule' job (replaced molecule-tests)"
def test_no_full_molecule_tests_job(self, workflow: dict) -> None:
assert "molecule-tests" not in workflow["jobs"], "ci.yml must NOT have 'molecule-tests' job (moved to nightly)"
def test_no_staging_deploy_in_ci(self, workflow: dict) -> None:
# The staging deploy was moved to post-merge (auto-deploy-staging)
job_names = list(workflow["jobs"].keys())
assert "staging-health-gate" not in job_names, "staging-health-gate removed from ci.yml (moved to nightly)"
assert "pre-deploy-checks" not in job_names, "pre-deploy-checks removed from ci.yml (moved to nightly)"
assert "deploy" not in job_names, "deploy job removed from ci.yml (moved to post-merge)"
def test_fast_molecule_uses_devx_module(self, workflow: dict) -> None:
job = workflow["jobs"]["fast-molecule"]
step = _find_step(job, "Detect changed roles")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.fast_molecule" in cmd
assert "--github-output" in cmd
def test_fast_molecule_timeout_is_short(self, workflow: dict) -> None:
job = workflow["jobs"]["fast-molecule"]
assert job.get("timeout-minutes", 999) <= 30, (
"fast-molecule timeout should be <= 30 min (was 120 for full suite)"
)
def test_fast_molecule_no_matrix(self, workflow: dict) -> None:
job = workflow["jobs"]["fast-molecule"]
assert "strategy" not in job or "matrix" not in job.get("strategy", {}), (
"fast-molecule should not use matrix (single runner)"
)
def test_auto_merge_depends_on_fast_molecule(self, workflow: dict) -> None:
job = workflow["jobs"].get("auto-merge", {})
needs = job.get("needs", [])
assert "fast-molecule" in needs, "auto-merge must depend on fast-molecule (not deploy)"
def test_auto_merge_does_not_depend_on_deploy(self, workflow: dict) -> None:
job = workflow["jobs"].get("auto-merge", {})
needs = job.get("needs", [])
assert "deploy" not in needs, "auto-merge must NOT depend on deploy (removed from PR pipeline)"
# ============================================================================
# Infra nightly.yml — full molecule + staging deploy + gate
# ============================================================================
class TestInfraNightlyWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_INFRA, "nightly.yml")
def test_nightly_workflow_exists(self, workflow: dict) -> None:
assert workflow is not None
def test_has_full_molecule_job(self, workflow: dict) -> None:
assert "full-molecule" in workflow["jobs"]
def test_has_set_gate_status_job(self, workflow: dict) -> None:
assert "set-gate-status" in workflow["jobs"]
def test_has_staging_deploy_job(self, workflow: dict) -> None:
assert "staging-deploy" in workflow["jobs"]
def test_full_molecule_uses_matrix(self, workflow: dict) -> None:
job = workflow["jobs"]["full-molecule"]
strategy = job.get("strategy", {})
assert "matrix" in strategy, "full-molecule must use matrix (6 runners)"
assert "runner-index" in strategy["matrix"]
def test_full_molecule_timeout_is_long(self, workflow: dict) -> None:
job = workflow["jobs"]["full-molecule"]
assert job.get("timeout-minutes", 0) >= 90, "full-molecule timeout should be >= 90 min (full suite)"
def test_set_gate_status_depends_on_full_molecule(self, workflow: dict) -> None:
job = workflow["jobs"]["set-gate-status"]
needs = job.get("needs", [])
assert "full-molecule" in needs
def test_set_gate_status_uses_nightly_gate_module(self, workflow: dict) -> None:
job = workflow["jobs"]["set-gate-status"]
step = _find_step(job, "Set nightly gate")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.nightly_gate" in cmd
assert "set-passed" in cmd or "set-failed" in cmd
def test_staging_deploy_depends_on_gate(self, workflow: dict) -> None:
job = workflow["jobs"]["staging-deploy"]
needs = job.get("needs", [])
assert "set-gate-status" in needs
assert "full-molecule" in needs
def test_staging_deploy_only_on_success(self, workflow: dict) -> None:
job = workflow["jobs"]["staging-deploy"]
if_cond = job.get("if", "")
assert "success" in if_cond, "staging-deploy must only run when full-molecule succeeds"
def test_nightly_runs_on_schedule(self, workflow: dict) -> None:
on = workflow.get("on", workflow.get(True, {}))
# YAML may parse 'on' as True (boolean)
if isinstance(on, dict):
assert "schedule" in on, "nightly must have schedule trigger"
else:
pytest.fail("Could not parse 'on' trigger from nightly.yml")
# ============================================================================
# Infra post-merge.yml — auto-deploy staging with nightly gate
# ============================================================================
class TestInfraPostMergeWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_INFRA, "post-merge.yml")
def test_has_auto_deploy_staging_job(self, workflow: dict) -> None:
assert "auto-deploy-staging" in workflow["jobs"], "post-merge must have 'auto-deploy-staging' job"
def test_has_staging_deploy_job(self, workflow: dict) -> None:
assert "staging-deploy" in workflow["jobs"], "post-merge must have 'staging-deploy' reusable workflow job"
def test_auto_deploy_staging_checks_nightly_gate(self, workflow: dict) -> None:
job = workflow["jobs"]["auto-deploy-staging"]
step = _find_step(job, "Check nightly gate")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.nightly_gate" in cmd
assert "--action check" in cmd
def test_staging_deploy_depends_on_auto_deploy_staging(self, workflow: dict) -> None:
job = workflow["jobs"]["staging-deploy"]
needs = job.get("needs", [])
assert "auto-deploy-staging" in needs
def test_staging_deploy_gated_on_gate_passed(self, workflow: dict) -> None:
job = workflow["jobs"]["staging-deploy"]
if_cond = job.get("if", "")
assert "gate-passed" in if_cond, "staging-deploy must check gate-passed output"
def test_auto_deploy_production_waits_for_staging(self, workflow: dict) -> None:
job = workflow["jobs"].get("auto-deploy-production", {})
needs = job.get("needs", [])
assert "staging-deploy" in needs, "auto-deploy-production must wait for staging-deploy"
# ============================================================================
# GRM ci.yml — spec validation + PR size
# ============================================================================
class TestGrmCiWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_GRM, "ci.yml")
def test_has_spec_validation_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Validate spec file" in s for s in steps)
def test_has_pr_size_check_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Check PR size" in s for s in steps)
def test_spec_validation_sets_task_prefix(self, workflow: dict) -> None:
step = _find_step(workflow["jobs"]["validate"], "Validate spec file")
assert step is not None
env = step.get("env", {})
assert env.get("DEVX_TASK_PREFIX") == "GRM"
# ============================================================================
# GRM post-merge.yml — auto-create infra dependency PR
# ============================================================================
class TestGrmPostMergeWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_GRM, "post-merge.yml")
def test_has_create_dependency_pr_step(self, workflow: dict) -> None:
job = workflow["jobs"].get("release-and-maintain", {})
step = _find_step(job, "Create infra dependency PR")
assert step is not None, "grm post-merge must have 'Create infra dependency PR' step"
def test_dependency_pr_uses_correct_module(self, workflow: dict) -> None:
job = workflow["jobs"].get("release-and-maintain", {})
step = _find_step(job, "Create infra dependency PR")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.create_dependency_pr" in cmd
assert "--package grm" in cmd
assert "--repo oblachno/infra" in cmd
def test_dependency_pr_is_best_effort(self, workflow: dict) -> None:
import re
job = workflow["jobs"].get("release-and-maintain", {})
step = _find_step(job, "Create infra dependency PR")
assert step is not None
cmd = _get_run_commands(step)
# Must not fail the workflow if PR creation fails.
# The || echo may be split across lines with backslash continuation in YAML.
# Normalize: remove backslashes and collapse whitespace.
cmd_normalized = " ".join(cmd.replace("\\", " ").split())
assert bool(re.search(r"\|\|\s*echo", cmd_normalized)) or "continue-on-error" in step, (
"dependency PR step must be best-effort (|| echo or continue-on-error)"
)
# ============================================================================
# sso-bridge ci.yml — spec validation + PR size
# ============================================================================
class TestSsoBridgeCiWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_SSO_BRIDGE, "ci.yml")
def test_has_spec_validation_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Validate spec file" in s for s in steps)
def test_has_pr_size_check_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Check PR size" in s for s in steps)
def test_spec_validation_sets_task_prefix(self, workflow: dict) -> None:
step = _find_step(workflow["jobs"]["validate"], "Validate spec file")
assert step is not None
env = step.get("env", {})
assert env.get("DEVX_TASK_PREFIX") == "SSO"
# ============================================================================
# sso-bridge post-merge.yml — auto-publish + auto-create dependency PR
# ============================================================================
class TestSsoBridgePostMergeWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_SSO_BRIDGE, "post-merge.yml")
def test_post_merge_workflow_exists(self, workflow: dict) -> None:
assert workflow is not None
def test_has_release_and_maintain_job(self, workflow: dict) -> None:
assert "release-and-maintain" in workflow["jobs"]
def test_has_create_dependency_pr_step(self, workflow: dict) -> None:
job = workflow["jobs"].get("release-and-maintain", {})
step = _find_step(job, "Create infra dependency PR")
assert step is not None
def test_dependency_pr_uses_correct_module(self, workflow: dict) -> None:
job = workflow["jobs"].get("release-and-maintain", {})
step = _find_step(job, "Create infra dependency PR")
assert step is not None
cmd = _get_run_commands(step)
assert "devx.ci.create_dependency_pr" in cmd
assert "--package sso_bridge" in cmd
assert "--repo oblachno/infra" in cmd
def test_dependency_pr_is_best_effort(self, workflow: dict) -> None:
import re
job = workflow["jobs"].get("release-and-maintain", {})
step = _find_step(job, "Create infra dependency PR")
assert step is not None
cmd = _get_run_commands(step)
# The || echo may be split across lines with backslash continuation in YAML.
cmd_normalized = " ".join(cmd.replace("\\", " ").split())
assert bool(re.search(r"\|\|\s*echo", cmd_normalized)) or "continue-on-error" in step
def test_has_publish_step(self, workflow: dict) -> None:
job = workflow["jobs"].get("release-and-maintain", {})
steps = _get_step_names(job)
assert any("publish" in s.lower() for s in steps), "sso-bridge post-merge must have a publish step"
# ============================================================================
# devx ci.yml — spec validation + PR size
# ============================================================================
class TestDevxCiWorkflow:
@pytest.fixture
def workflow(self) -> dict:
return _load_workflow(_DEVX, "ci.yml")
def test_has_spec_validation_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Validate spec file" in s for s in steps)
def test_has_pr_size_check_step(self, workflow: dict) -> None:
steps = _get_step_names(workflow["jobs"]["validate"])
assert any("Check PR size" in s for s in steps)
def test_spec_validation_sets_task_prefix(self, workflow: dict) -> None:
step = _find_step(workflow["jobs"]["validate"], "Validate spec file")
assert step is not None
env = step.get("env", {})
assert env.get("DEVX_TASK_PREFIX") == "DEVX"
# ============================================================================
# Skill files — spec-driven-development SKILL.md in all repos
# ============================================================================
class TestSpecDrivenDevelopmentSkill:
REQUIRED_SECTIONS = [
"## Overview",
"## Workflow",
"## Spec Template",
"## CI Validation",
"## Acceptance Criteria",
]
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_skill_exists_in_repo(self, repo_name: str) -> None:
skill_path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "spec-driven-development" / "SKILL.md"
assert skill_path.exists(), f"SKILL.md not found in {repo_name}"
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_skill_has_required_sections(self, repo_name: str) -> None:
skill_path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "spec-driven-development" / "SKILL.md"
content = skill_path.read_text(encoding="utf-8")
for section in self.REQUIRED_SECTIONS:
assert section in content, f"SKILL.md in {repo_name} missing section: {section}"
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_skill_mentions_req_ids(self, repo_name: str) -> None:
skill_path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "spec-driven-development" / "SKILL.md"
content = skill_path.read_text(encoding="utf-8")
assert "REQ-" in content, f"SKILL.md in {repo_name} must mention REQ-ID format"
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_skill_mentions_pr_size_limit(self, repo_name: str) -> None:
skill_path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "spec-driven-development" / "SKILL.md"
content = skill_path.read_text(encoding="utf-8")
assert "500" in content, f"SKILL.md in {repo_name} must mention 500 line PR size limit"
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_skill_mentions_nightly_gate(self, repo_name: str) -> None:
skill_path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "spec-driven-development" / "SKILL.md"
content = skill_path.read_text(encoding="utf-8")
assert "nightly" in content.lower(), f"SKILL.md in {repo_name} must mention nightly gate"
def test_skill_exists_in_shared_dir(self) -> None:
skill_path = _OBLACHNO_ROOT / ".devin" / "skills" / "spec-driven-development" / "SKILL.md"
assert skill_path.exists(), "SKILL.md not found in shared .devin/skills/"
# ============================================================================
# devx-workflow skill — exists in repos with PR workflow, mentions spec gates
# ============================================================================
class TestDevxWorkflowSkill:
# Repos that have a PR workflow and need the devx-workflow skill
REPOS_WITH_PR_WORKFLOW = ["infra", "grm", "sso-bridge", "devx"]
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_skill_exists(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
assert path.exists(), f"devx-workflow SKILL.md not found in {repo_name}"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_spec_validation(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "validate_spec" in content, f"devx-workflow skill in {repo_name} must mention validate_spec"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_pr_size_check(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "check_pr_size" in content, f"devx-workflow skill in {repo_name} must mention check_pr_size"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_pr_workflow_commands(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "make create-pr" in content or "make push-with-pr" in content, (
f"devx-workflow skill in {repo_name} must mention PR creation commands"
)
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_auto_merge(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "auto-merge" in content.lower() or "ready-to-merge" in content, (
f"devx-workflow skill in {repo_name} must mention auto-merge"
)
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_has_correct_task_prefix(self, repo_name: str) -> None:
"""Each repo's devx-workflow skill must mention its correct task prefix."""
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
expected_prefixes = {
"infra": "OBL-INFRA",
"grm": "GRM",
"sso-bridge": "SSO",
"devx": "DEVX",
}
prefix = expected_prefixes[repo_name]
assert prefix in content, f"devx-workflow skill in {repo_name} must mention task prefix {prefix}"
def test_not_in_mattermost_oidc(self) -> None:
"""mattermost-oidc has no PR workflow — should NOT have devx-workflow skill."""
path = _OBLACHNO_ROOT / "mattermost-oidc" / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
assert not path.exists(), "mattermost-oidc should NOT have devx-workflow skill (no PR workflow)"
# Repo-specific content checks
def test_infra_mentions_nightly_gate(self) -> None:
path = _OBLACHNO_ROOT / "infra" / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "nightly" in content.lower(), "infra devx-workflow skill must mention nightly gate"
assert "nightly_gate" in content, "infra devx-workflow skill must mention devx.ci.nightly_gate module"
def test_infra_mentions_fast_molecule(self) -> None:
path = _OBLACHNO_ROOT / "infra" / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "fast_molecule" in content, "infra devx-workflow skill must mention devx.ci.fast_molecule"
def test_infra_mentions_auto_deploy_staging(self) -> None:
path = _OBLACHNO_ROOT / "infra" / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "staging" in content.lower(), "infra devx-workflow skill must mention staging auto-deploy"
def test_grm_mentions_dependency_pr(self) -> None:
path = _OBLACHNO_ROOT / "grm" / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "create_dependency_pr" in content, "grm devx-workflow skill must mention create_dependency_pr"
def test_sso_bridge_mentions_dependency_pr(self) -> None:
path = _OBLACHNO_ROOT / "sso-bridge" / ".devin" / "skills" / "devx-workflow" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "create_dependency_pr" in content, "sso-bridge devx-workflow skill must mention create_dependency_pr"
# ============================================================================
# testing-and-debugging skill — exists in all repos, mentions spec workflow
# ============================================================================
class TestTestingAndDebuggingSkill:
# All repos have a testing-and-debugging skill
ALL_REPOS = ["infra", "grm", "sso-bridge", "devx", "mattermost-oidc"]
@pytest.mark.parametrize("repo_name", ALL_REPOS)
def test_skill_exists(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "testing-and-debugging" / "SKILL.md"
assert path.exists(), f"testing-and-debugging SKILL.md not found in {repo_name}"
@pytest.mark.parametrize("repo_name", ALL_REPOS)
def test_has_required_sections(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "testing-and-debugging" / "SKILL.md"
content = path.read_text(encoding="utf-8")
# All testing-and-debugging skills should have a CI failure investigation section
assert "CI Failure Investigation" in content or "CI failure" in content, (
f"testing-and-debugging skill in {repo_name} must have CI failure section"
)
# Repos with PR workflow should mention spec-driven workflow
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_mentions_spec_driven_workflow(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "testing-and-debugging" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "spec" in content.lower(), (
f"testing-and-debugging skill in {repo_name} must mention spec-driven workflow"
)
def test_infra_mentions_nightly(self) -> None:
path = _OBLACHNO_ROOT / "infra" / ".devin" / "skills" / "testing-and-debugging" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "nightly" in content.lower(), "infra testing-and-debugging skill must mention nightly tests"
def test_infra_mentions_fast_molecule(self) -> None:
path = _OBLACHNO_ROOT / "infra" / ".devin" / "skills" / "testing-and-debugging" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "fast" in content.lower() and "molecule" in content.lower(), (
"infra testing-and-debugging skill must mention fast molecule"
)
def test_mattermost_oidc_no_spec_mention(self) -> None:
"""mattermost-oidc has no spec-driven workflow — skill should NOT mention it."""
path = _OBLACHNO_ROOT / "mattermost-oidc" / ".devin" / "skills" / "testing-and-debugging" / "SKILL.md"
content = path.read_text(encoding="utf-8")
# mattermost-oidc has no PR workflow, no spec validation
assert "validate_spec" not in content, (
"mattermost-oidc testing-and-debugging skill should NOT mention validate_spec"
)
# ============================================================================
# pr-review skill — deep review with auto-fix, exists in repos with PR workflow
# ============================================================================
class TestPrReviewSkill:
REPOS_WITH_PR_WORKFLOW = ["infra", "grm", "sso-bridge", "devx"]
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_skill_exists(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
assert path.exists(), f"pr-review SKILL.md not found in {repo_name}"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_all_review_categories(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
required_categories = [
"Functional Correctness",
"Completeness",
"Architecture",
"Reliability",
"Robustness",
"Security",
"Technical Excellence",
"Test Quality",
]
for cat in required_categories:
assert cat in content, f"pr-review skill in {repo_name} missing category: {cat}"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_auto_fix(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "auto-fix" in content.lower() or "auto fix" in content.lower(), (
f"pr-review skill in {repo_name} must mention auto-fix"
)
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_gitea_mcp(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "mcp" in content.lower(), f"pr-review skill in {repo_name} must mention Gitea MCP"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_inline_comments(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "inline" in content.lower(), f"pr-review skill in {repo_name} must mention inline comments"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_ready_to_merge(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "ready-to-merge" in content, f"pr-review skill in {repo_name} must mention ready-to-merge label"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_resolve_discussion(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "resolve" in content.lower(), f"pr-review skill in {repo_name} must mention resolving discussions"
@pytest.mark.parametrize("repo_name", REPOS_WITH_PR_WORKFLOW)
def test_mentions_summary(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / "pr-review" / "SKILL.md"
content = path.read_text(encoding="utf-8")
assert "summary" in content.lower(), f"pr-review skill in {repo_name} must mention posting a summary"
def test_not_in_mattermost_oidc(self) -> None:
"""mattermost-oidc has no PR workflow — should NOT have pr-review skill."""
path = _OBLACHNO_ROOT / "mattermost-oidc" / ".devin" / "skills" / "pr-review" / "SKILL.md"
assert not path.exists(), "mattermost-oidc should NOT have pr-review skill (no PR workflow)"
def test_no_pr_review_module_remains(self) -> None:
"""The old devx.ci.pr_review module should be deleted."""
path = _DEVX / "src" / "devx" / "ci" / "pr_review.py"
assert not path.exists(), "devx.ci.pr_review module should be deleted (replaced by pr-review skill)"
def test_no_pr_review_test_remains(self) -> None:
"""The old test_pr_review.py should be deleted."""
path = _DEVX / "tests" / "unit" / "test_pr_review.py"
assert not path.exists(), "tests/unit/test_pr_review.py should be deleted"
def test_no_pr_review_in_workflows(self) -> None:
"""No CI workflow should reference devx.ci.pr_review."""
for repo_name in ["infra", "grm", "sso-bridge", "devx"]:
wf_dir = _OBLACHNO_ROOT / repo_name / ".gitea" / "workflows"
if not wf_dir.exists():
continue
for wf_file in wf_dir.glob("*.yml"):
content = wf_file.read_text(encoding="utf-8")
assert "devx.ci.pr_review" not in content, (
f"{repo_name}/{wf_file.name} still references devx.ci.pr_review"
)
# ============================================================================
# Skill consistency — all skills have proper structure
# ============================================================================
class TestSkillConsistency:
ALL_SKILLS = [
("infra", "devx-workflow"),
("infra", "testing-and-debugging"),
("infra", "spec-driven-development"),
("infra", "pr-review"),
("grm", "devx-workflow"),
("grm", "testing-and-debugging"),
("grm", "spec-driven-development"),
("grm", "pr-review"),
("sso-bridge", "devx-workflow"),
("sso-bridge", "testing-and-debugging"),
("sso-bridge", "spec-driven-development"),
("sso-bridge", "pr-review"),
("devx", "devx-workflow"),
("devx", "testing-and-debugging"),
("devx", "spec-driven-development"),
("devx", "pr-review"),
("mattermost-oidc", "testing-and-debugging"),
]
@pytest.mark.parametrize("repo_name, skill_name", ALL_SKILLS)
def test_skill_has_title(self, repo_name: str, skill_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / skill_name / "SKILL.md"
content = path.read_text(encoding="utf-8")
first_line = content.strip().split("\n")[0]
assert first_line.startswith("# "), f"{repo_name}/{skill_name}: SKILL.md must start with a # title"
@pytest.mark.parametrize("repo_name, skill_name", ALL_SKILLS)
def test_skill_not_empty(self, repo_name: str, skill_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / skill_name / "SKILL.md"
content = path.read_text(encoding="utf-8").strip()
assert len(content) > 100, f"{repo_name}/{skill_name}: SKILL.md is too short ({len(content)} chars)"
@pytest.mark.parametrize("repo_name, skill_name", ALL_SKILLS)
def test_skill_has_sections(self, repo_name: str, skill_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / ".devin" / "skills" / skill_name / "SKILL.md"
content = path.read_text(encoding="utf-8")
# Must have at least 2 ## sections
section_count = content.count("\n## ")
assert section_count >= 2, (
f"{repo_name}/{skill_name}: SKILL.md must have at least 2 sections (found {section_count})"
)
# ============================================================================
# AGENTS.md — spec-driven development section in all repos
# ============================================================================
class TestAgentsMdSpecSection:
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_agents_md_has_spec_driven_section(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / "AGENTS.md"
if not path.exists():
pytest.skip(f"AGENTS.md not found in {repo_name}")
content = path.read_text(encoding="utf-8")
assert "## Spec-Driven Development" in content, (
f"AGENTS.md in {repo_name} must have '## Spec-Driven Development' section"
)
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_agents_md_mentions_validate_spec(self, repo_name: str) -> None:
path = _OBLACHNO_ROOT / repo_name / "AGENTS.md"
if not path.exists():
pytest.skip(f"AGENTS.md not found in {repo_name}")
content = path.read_text(encoding="utf-8")
assert "validate_spec" in content or "devx.ci.validate_spec" in content, (
f"AGENTS.md in {repo_name} must mention devx.ci.validate_spec"
)
@pytest.mark.parametrize("repo_name", ["infra", "grm", "sso-bridge", "devx"])
def test_agents_md_pr_workflow_section_intact(self, repo_name: str) -> None:
"""Ensure the PR Workflow section wasn't accidentally deleted."""
path = _OBLACHNO_ROOT / repo_name / "AGENTS.md"
if not path.exists():
pytest.skip(f"AGENTS.md not found in {repo_name}")
content = path.read_text(encoding="utf-8")
assert "## PR Workflow" in content, f"AGENTS.md in {repo_name} must still have '## PR Workflow' section"
+223
View File
@@ -0,0 +1,223 @@
"""Unit tests for devx.ci.validate_spec."""
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.ci.validate_spec import (
AC_CHECKED_RE,
AC_UNCHECKED_RE,
REQ_ID_RE,
cli,
find_spec_file,
validate_spec_content,
)
VALID_SPEC = """\
# OBL-INFRA-531: Fix sso-bridge role for pip install
## Problem
The sso-bridge role uses scripts.sso_bridge.listener but the pip
package uses sso_bridge.listener.
## Approach
REQ-1: Update molecule verify.yml to use sso_bridge.listener
REQ-2: Add infra repo clone task to sso_bridge role
## Test Plan
- Run molecule test for sso_bridge role
- Verify pip package is installed correctly
## Deploy Plan
- Merge PR
- Auto-deploy to staging
## Rollback Plan
- Revert PR
- Re-deploy previous version
## Acceptance Criteria
- [x] Molecule test passes with sso_bridge.listener
- [x] Infra repo is cloned by sso_bridge role
"""
SPEC_MISSING_SECTION = """\
# OBL-INFRA-531: Fix sso-bridge
## Problem
Something is broken.
## Approach
REQ-1: Fix it
## Test Plan
Run tests
"""
SPEC_UNCHECKED_AC = """\
# OBL-INFRA-531: Fix sso-bridge
## Problem
Broken.
## Approach
REQ-1: Fix it
## Test Plan
Run tests
## Deploy Plan
Deploy
## Rollback Plan
Revert
## Acceptance Criteria
- [x] Fixed
- [ ] Verified in staging
"""
SPEC_NO_REQ_IDS = """\
# OBL-INFRA-531: Fix sso-bridge
## Problem
Broken.
## Approach
Fix it.
## Test Plan
Run tests
## Deploy Plan
Deploy
## Rollback Plan
Revert
## Acceptance Criteria
- [x] Fixed
"""
class TestFindSpecFile:
def test_finds_exact_match(self, tmp_path: Path) -> None:
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
(specs_dir / "OBL-INFRA-531.md").write_text("content")
result = find_spec_file("OBL-INFRA-531", str(specs_dir))
assert result is not None
assert result.name == "OBL-INFRA-531.md"
def test_finds_case_insensitive(self, tmp_path: Path) -> None:
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
(specs_dir / "obl-infra-531.md").write_text("content")
result = find_spec_file("OBL-INFRA-531", str(specs_dir))
assert result is not None
def test_returns_none_when_not_found(self, tmp_path: Path) -> None:
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
result = find_spec_file("OBL-INFRA-999", str(specs_dir))
assert result is None
def test_returns_none_when_dir_missing(self, tmp_path: Path) -> None:
result = find_spec_file("OBL-INFRA-531", str(tmp_path / "nonexistent"))
assert result is None
class TestValidateSpecContent:
def test_valid_spec_passes(self) -> None:
errors = validate_spec_content(VALID_SPEC)
assert errors == []
def test_missing_sections(self) -> None:
errors = validate_spec_content(SPEC_MISSING_SECTION)
assert len(errors) >= 3 # Missing Deploy Plan, Rollback Plan, Acceptance Criteria
assert any("Deploy Plan" in e for e in errors)
assert any("Rollback Plan" in e for e in errors)
assert any("Acceptance Criteria" in e for e in errors)
def test_unchecked_ac_fails(self) -> None:
errors = validate_spec_content(SPEC_UNCHECKED_AC)
assert len(errors) == 1
assert "unchecked" in errors[0].lower()
def test_no_req_ids_fails(self) -> None:
errors = validate_spec_content(SPEC_NO_REQ_IDS)
assert any("REQ-ID" in e for e in errors)
def test_empty_content_fails(self) -> None:
errors = validate_spec_content("")
assert len(errors) >= 2 # Missing sections + no REQ-IDs
class TestRegexPatterns:
def test_req_id_re_matches(self) -> None:
assert REQ_ID_RE.search("REQ-1: Do something")
assert REQ_ID_RE.search("REQ-42: Another thing")
assert not REQ_ID_RE.search("REQ: no number")
def test_ac_checked_re_matches(self) -> None:
assert AC_CHECKED_RE.search("- [x] Done")
assert AC_CHECKED_RE.search(" - [x] Indented")
assert not AC_CHECKED_RE.search("- [ ] Not done")
def test_ac_unchecked_re_matches(self) -> None:
assert AC_UNCHECKED_RE.search("- [ ] Not done")
assert AC_UNCHECKED_RE.search(" - [ ] Indented")
assert not AC_UNCHECKED_RE.search("- [x] Done")
class TestCli:
def test_fails_without_task_id(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "no-task-id"])
assert result.exit_code != 0
def test_allow_missing_succeeds_without_task_id(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--branch", "no-task-id", "--allow-missing"])
assert result.exit_code == 0
assert "WARNING" in result.output
def test_fails_when_spec_not_found(self, tmp_path: Path) -> None:
runner = CliRunner()
with patch("devx.ci.validate_spec.extract_task_id", return_value="OBL-INFRA-999"):
result = runner.invoke(
cli,
["--branch", "OBL-INFRA-999-test", "--specs-dir", str(tmp_path / "specs")],
)
assert result.exit_code != 0
assert "No spec file found" in result.output
def test_passes_with_valid_spec(self, tmp_path: Path) -> None:
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
(specs_dir / "OBL-INFRA-531.md").write_text(VALID_SPEC)
runner = CliRunner()
with patch("devx.ci.validate_spec.extract_task_id", return_value="OBL-INFRA-531"):
result = runner.invoke(
cli,
["--branch", "OBL-INFRA-531-fix-foo", "--specs-dir", str(specs_dir)],
)
assert result.exit_code == 0
assert "Spec validated" in result.output
def test_fails_with_unchecked_ac(self, tmp_path: Path) -> None:
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
(specs_dir / "OBL-INFRA-531.md").write_text(SPEC_UNCHECKED_AC)
runner = CliRunner()
with patch("devx.ci.validate_spec.extract_task_id", return_value="OBL-INFRA-531"):
result = runner.invoke(
cli,
["--branch", "OBL-INFRA-531-fix-foo", "--specs-dir", str(specs_dir)],
)
assert result.exit_code != 0
assert "unchecked" in result.output.lower()