diff --git a/.devin/skills/devx-workflow/SKILL.md b/.devin/skills/devx-workflow/SKILL.md index c64f118..6582320 100644 --- a/.devin/skills/devx-workflow/SKILL.md +++ b/.devin/skills/devx-workflow/SKILL.md @@ -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/.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 diff --git a/.devin/skills/pr-review/SKILL.md b/.devin/skills/pr-review/SKILL.md new file mode 100644 index 0000000..967ec1f --- /dev/null +++ b/.devin/skills/pr-review/SKILL.md @@ -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/.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: + repo: + pull_number: + state: PENDING (accumulate comments before submitting) + body: "" (empty for now, summary added on submit) + comments: [ + { + path: "", + new_line_num: , + body: "**[] []** \n\n**Suggested fix:**\n```\n\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 — ` +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 . 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: + repo: + pull_number: + review_id: + state: COMMENT (or APPROVED if no blocking issues remain) + body: +``` + +### 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: + repo: + issue_number: + labels: [] +``` + +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 `-N: ` 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. diff --git a/.devin/skills/spec-driven-development/SKILL.md b/.devin/skills/spec-driven-development/SKILL.md new file mode 100644 index 0000000..8fef430 --- /dev/null +++ b/.devin/skills/spec-driven-development/SKILL.md @@ -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/.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/.md` (see template below) +3. **Create branch** — `git checkout -b -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 +# : + +## 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 +``` diff --git a/.devin/skills/testing-and-debugging/SKILL.md b/.devin/skills/testing-and-debugging/SKILL.md index 21c5d85..a6ab9eb 100644 --- a/.devin/skills/testing-and-debugging/SKILL.md +++ b/.devin/skills/testing-and-debugging/SKILL.md @@ -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: diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e1eee70..8be61af 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index 600f50d..1086e35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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): diff --git a/docs/specs/DEVX-155.md b/docs/specs/DEVX-155.md new file mode 100644 index 0000000..7c77e61 --- /dev/null +++ b/docs/specs/DEVX-155.md @@ -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 diff --git a/src/devx/ci/check_pr_size.py b/src/devx/ci/check_pr_size.py new file mode 100644 index 0000000..25dab05 --- /dev/null +++ b/src/devx/ci/check_pr_size.py @@ -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() diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py new file mode 100644 index 0000000..9e1a196 --- /dev/null +++ b/src/devx/ci/create_dependency_pr.py @@ -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() diff --git a/src/devx/ci/doc_coverage.py b/src/devx/ci/doc_coverage.py index 2063c7b..b11f6a9 100644 --- a/src/devx/ci/doc_coverage.py +++ b/src/devx/ci/doc_coverage.py @@ -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", diff --git a/src/devx/ci/fast_molecule.py b/src/devx/ci/fast_molecule.py new file mode 100644 index 0000000..19817ff --- /dev/null +++ b/src/devx/ci/fast_molecule.py @@ -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() diff --git a/src/devx/ci/nightly_gate.py b/src/devx/ci/nightly_gate.py new file mode 100644 index 0000000..fae7b02 --- /dev/null +++ b/src/devx/ci/nightly_gate.py @@ -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() diff --git a/src/devx/ci/pr_review.py b/src/devx/ci/pr_review.py deleted file mode 100644 index 1d79715..0000000 --- a/src/devx/ci/pr_review.py +++ /dev/null @@ -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() diff --git a/src/devx/ci/validate_spec.py b/src/devx/ci/validate_spec.py new file mode 100644 index 0000000..96acaaa --- /dev/null +++ b/src/devx/ci/validate_spec.py @@ -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() diff --git a/src/devx/cli.py b/src/devx/cli.py index 758e33f..dd019a1 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -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: diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index 828ac8e..8e66123 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -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 diff --git a/src/devx/tokens.py b/src/devx/tokens.py index 25110aa..6ea30db 100644 --- a/src/devx/tokens.py +++ b/src/devx/tokens.py @@ -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) diff --git a/src/devx/translations.json b/src/devx/translations.json index dc3f151..2f9a691 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -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)" } } diff --git a/tests/unit/test_check_pr_size.py b/tests/unit/test_check_pr_size.py new file mode 100644 index 0000000..d1ac7ef --- /dev/null +++ b/tests/unit/test_check_pr_size.py @@ -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 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 4f05ea0..abbfb80 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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() diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py new file mode 100644 index 0000000..9449160 --- /dev/null +++ b/tests/unit/test_create_dependency_pr.py @@ -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" diff --git a/tests/unit/test_fast_molecule.py b/tests/unit/test_fast_molecule.py new file mode 100644 index 0000000..cbaed11 --- /dev/null +++ b/tests/unit/test_fast_molecule.py @@ -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 diff --git a/tests/unit/test_nightly_gate.py b/tests/unit/test_nightly_gate.py new file mode 100644 index 0000000..255acd8 --- /dev/null +++ b/tests/unit/test_nightly_gate.py @@ -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 diff --git a/tests/unit/test_pr_review.py b/tests/unit/test_pr_review.py deleted file mode 100644 index 70dd239..0000000 --- a/tests/unit/test_pr_review.py +++ /dev/null @@ -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([]) diff --git a/tests/unit/test_spec_driven_workflows.py b/tests/unit/test_spec_driven_workflows.py new file mode 100644 index 0000000..6f862e8 --- /dev/null +++ b/tests/unit/test_spec_driven_workflows.py @@ -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" diff --git a/tests/unit/test_validate_spec.py b/tests/unit/test_validate_spec.py new file mode 100644 index 0000000..83d0eee --- /dev/null +++ b/tests/unit/test_validate_spec.py @@ -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()