diff --git a/.github/workflows/toolkit.yml b/.github/workflows/toolkit.yml new file mode 100644 index 0000000..46f32cd --- /dev/null +++ b/.github/workflows/toolkit.yml @@ -0,0 +1,38 @@ +name: Agentics Toolkit + +on: + push: + branches: [main] + paths: ["src/**", "tests/**", "skills/**", "agents/**", "workflows/**", "references/**", "catalog.json", "pyproject.toml", ".github/workflows/toolkit.yml"] + pull_request: + paths: ["src/**", "tests/**", "skills/**", "agents/**", "workflows/**", "references/**", "catalog.json", "pyproject.toml", ".github/workflows/toolkit.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.11", "3.14"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install '.[test]' build + - run: agentics validate + - run: python -m unittest discover -s tests -v + - run: python -m build + - name: Verify isolated wheel installation + shell: bash + run: | + python -m venv "$RUNNER_TEMP/agentics-wheel" + "$RUNNER_TEMP/agentics-wheel/bin/python" -m pip install dist/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/agentics-wheel/bin/agentics" validate + "$RUNNER_TEMP/agentics-wheel/bin/agentics" install --all --home "$RUNNER_TEMP/agentics-home" + "$RUNNER_TEMP/agentics-wheel/bin/agentics" status --home "$RUNNER_TEMP/agentics-home" --json + "$RUNNER_TEMP/agentics-wheel/bin/agentics" uninstall --all --home "$RUNNER_TEMP/agentics-home" diff --git a/.gitignore b/.gitignore index bab92ef..3b41076 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ # macOS Finder metadata .DS_Store **/.DS_Store + +# Python toolkit builds and local environments +__pycache__/ +.venv/ +dist/ +build/ diff --git a/AGENTS.md b/AGENTS.md index 6e69392..22229ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,27 @@ Central repository for reusable AI agent configuration, rules, workflows, and or ## Key Commands +### Codex toolkit + +```bash +python3 -m pip install '.[test]' build +agentics validate +python3 -m unittest discover -s tests -v +python3 -m build +agentics install --all --dry-run +agentics install --all +agentics status +agentics update --dry-run +``` + +- Edit canonical sources here; global copies are managed installations, not editable source. +- Keep skills portable by discovering the target repository's root/scoped guidance and native commands. Project policy, private operational context, and historical markers stay with their owning projects. +- Maintain catalog IDs, dependencies, bundled-resource links, and existing invocation policies together. Shared custom agents inherit model/reasoning and retain review-only instructions. +- Every install/update/uninstall must preflight ownership and all selected destinations. Refuse unknown, modified, missing, or additional managed files; dry-run must not create state. +- Helper target repositories come from explicit `--repo-root` or Git CWD discovery, never the installed package location. Tests mock external writes. +- Installing workflows does not configure schedules or authorize Git/GitHub publication or live deployment. Keep conventional CI/CD with its owning project. +- Wheel builds must include the complete catalog. Verify the wheel from outside the checkout as well as source tests. + ### gh aw (Agentic Workflows) ```bash @@ -50,11 +71,12 @@ pre-commit run -a # Run all hooks ## Architecture -### Three Subprojects +### Four Subprojects -1. **`.github/workflows/`** — gh aw agentic workflow definitions (markdown → compiled YAML) -2. **precommit-agentic-check/** — Standalone Python package (`agentic-check` CLI) that runs LLM-backed pre-commit checks on staged diffs. Packaged with setuptools, requires Python ≥3.10, published to PyPI on tags matching `precommit-agentic-check-v*`. -3. **claude-commands/** — Claude Code custom slash commands (`/review`, `/refine`, `/suggest`, `/design`, `/daily`, `/ticket`) and shared reference files. This repo is the source of truth; files are deployed to `~/.claude/` via `make deploy`. +1. **Codex toolkit** — `src/agentics_tools/` supplies the `agentics` installer and portable GitHub helpers. Canonical `skills/`, `agents/`, `workflows/`, and `references/` are assembled by `catalog.json` and bundled in the Python wheel. +2. **`.github/workflows/`** — gh aw agentic workflow definitions (markdown → compiled YAML) +3. **precommit-agentic-check/** — Standalone Python package (`agentic-check` CLI) that runs LLM-backed pre-commit checks on staged diffs. Packaged with setuptools, requires Python ≥3.10, published to PyPI on tags matching `precommit-agentic-check-v*`. +4. **claude-commands/** — Claude Code custom slash commands (`/review`, `/refine`, `/suggest`, `/design`, `/daily`, `/ticket`) and shared reference files. This repo is the source of truth; files are deployed to `~/.claude/` via `make deploy`. ### Claude Commands @@ -136,4 +158,5 @@ Supported engines: `copilot`, `claude`, `codex`, or custom. - Never merge Dependabot PRs that modify generated manifests (`.github/workflows/package.json`, etc.) — update the source `.md` files and rerun `gh aw compile --dependabot` - Workflow source `.md` files live in `.github/workflows/` — author and edit them there directly. - The `CLAUDE.md` and `AGENTS.md` files must stay in sync (the `agents-doc-sync` workflow enforces this) +- **Maintenance playbooks**: `workflows/` contains manually requested procedures, distinct from gh-aw sources under `.github/workflows/`. Do not recreate retired schedulers. - **claude-commands**: Never edit files directly in `~/.claude/commands/` or `~/.claude/shared/` — edit in this repo, then `make deploy`. Commands reference shared files via `~/.claude/shared/...` paths resolved at runtime. diff --git a/CLAUDE.md b/CLAUDE.md index b96e08b..22229ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to agentic coding assistants (such as OpenAI Codex) when working with code in this repository. It mirrors `CLAUDE.md` — the two are kept in sync by the `agents-doc-sync` workflow. ## Repository Purpose @@ -8,6 +8,27 @@ Central repository for reusable AI agent configuration, rules, workflows, and or ## Key Commands +### Codex toolkit + +```bash +python3 -m pip install '.[test]' build +agentics validate +python3 -m unittest discover -s tests -v +python3 -m build +agentics install --all --dry-run +agentics install --all +agentics status +agentics update --dry-run +``` + +- Edit canonical sources here; global copies are managed installations, not editable source. +- Keep skills portable by discovering the target repository's root/scoped guidance and native commands. Project policy, private operational context, and historical markers stay with their owning projects. +- Maintain catalog IDs, dependencies, bundled-resource links, and existing invocation policies together. Shared custom agents inherit model/reasoning and retain review-only instructions. +- Every install/update/uninstall must preflight ownership and all selected destinations. Refuse unknown, modified, missing, or additional managed files; dry-run must not create state. +- Helper target repositories come from explicit `--repo-root` or Git CWD discovery, never the installed package location. Tests mock external writes. +- Installing workflows does not configure schedules or authorize Git/GitHub publication or live deployment. Keep conventional CI/CD with its owning project. +- Wheel builds must include the complete catalog. Verify the wheel from outside the checkout as well as source tests. + ### gh aw (Agentic Workflows) ```bash @@ -50,11 +71,12 @@ pre-commit run -a # Run all hooks ## Architecture -### Three Subprojects +### Four Subprojects -1. **`.github/workflows/`** — gh aw agentic workflow definitions (markdown → compiled YAML) -2. **precommit-agentic-check/** — Standalone Python package (`agentic-check` CLI) that runs LLM-backed pre-commit checks on staged diffs. Packaged with setuptools, requires Python ≥3.10, published to PyPI on tags matching `precommit-agentic-check-v*`. -3. **claude-commands/** — Claude Code custom slash commands (`/review`, `/refine`, `/suggest`, `/design`, `/daily`, `/ticket`) and shared reference files. This repo is the source of truth; files are deployed to `~/.claude/` via `make deploy`. +1. **Codex toolkit** — `src/agentics_tools/` supplies the `agentics` installer and portable GitHub helpers. Canonical `skills/`, `agents/`, `workflows/`, and `references/` are assembled by `catalog.json` and bundled in the Python wheel. +2. **`.github/workflows/`** — gh aw agentic workflow definitions (markdown → compiled YAML) +3. **precommit-agentic-check/** — Standalone Python package (`agentic-check` CLI) that runs LLM-backed pre-commit checks on staged diffs. Packaged with setuptools, requires Python ≥3.10, published to PyPI on tags matching `precommit-agentic-check-v*`. +4. **claude-commands/** — Claude Code custom slash commands (`/review`, `/refine`, `/suggest`, `/design`, `/daily`, `/ticket`) and shared reference files. This repo is the source of truth; files are deployed to `~/.claude/` via `make deploy`. ### Claude Commands @@ -136,4 +158,5 @@ Supported engines: `copilot`, `claude`, `codex`, or custom. - Never merge Dependabot PRs that modify generated manifests (`.github/workflows/package.json`, etc.) — update the source `.md` files and rerun `gh aw compile --dependabot` - Workflow source `.md` files live in `.github/workflows/` — author and edit them there directly. - The `CLAUDE.md` and `AGENTS.md` files must stay in sync (the `agents-doc-sync` workflow enforces this) +- **Maintenance playbooks**: `workflows/` contains manually requested procedures, distinct from gh-aw sources under `.github/workflows/`. Do not recreate retired schedulers. - **claude-commands**: Never edit files directly in `~/.claude/commands/` or `~/.claude/shared/` — edit in this repo, then `make deploy`. Commands reference shared files via `~/.claude/shared/...` paths resolved at runtime. diff --git a/README.md b/README.md index 7e92fce..15f41e9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,88 @@ > CLI tools, slash commands, and workflows that put AI agents to work across the development lifecycle — from code review to workflow automation. +## Global Codex toolkit + +Agentics is the source of truth for 26 reusable skills (25 extracted workflows plus a maintenance router), 13 custom agents, and ten maintenance playbooks. Project commands, product requirements, infrastructure details, and PR policies stay in each project's own guidance. + +Install the Python 3.11+ command from a checkout: + +```bash +uv tool install . +agentics validate +agentics install --all --dry-run +agentics install --all +``` + +Without uv, install into a dedicated virtual environment with `python3 -m venv .venv` and `.venv/bin/python -m pip install .`, then use `.venv/bin/agentics` for the commands below. Installing the package alone does not install skills or change Codex settings. + +```bash +agentics list +agentics install --group core --group github +agentics install --skill stack-rollout --agent simplicity_reviewer +agentics status --json +agentics update --dry-run +agentics update +agentics uninstall --all --dry-run +``` + +| Group | Capabilities | +| --- | --- | +| `core` | Change planning, reproduction, cohesion, tests, verification, and final audit | +| `github` | PR review, refinement, conflicts, comments, drafts, metadata, previews, and gates | +| `docs` | Change documentation, documentation audits and maintenance, backlog work, and capability maintenance | +| `ops` | Deployment safety, Cloudflare, Dokploy, service integration, rollout, and observability | +| `maintenance` | Requested maintenance playbooks and coordination | + +Selections include declared dependencies. Omit a selection for `status` to inspect the whole catalog; omit it for `update` or `uninstall` to operate on owned installations. `update --all` only updates already installed items. Removing one skill leaves its supporting roles installed; removing an agent still required by an installed skill is refused. + +Skills are copied to `~/.agents/skills/`; agents go to `${CODEX_HOME:-~/.codex}/agents/`. Ownership and content hashes live in `${CODEX_HOME:-~/.codex}/agentics/installed.json`. `--home` and `--codex-home` allow isolated installations and tests. Pulling this repo changes no installed tools: upgrade the command with `uv tool upgrade agentics-toolkit --reinstall`, then run `agentics update`. For a local checkout, rerun `uv tool install --reinstall .` before updating. + +The installer checks the complete operation before writing, preserves executable resources, and refuses unknown collisions or locally changed, missing, or additional bundle files. Keep custom edits outside managed copies. If rollback cannot finish, the installer retains its lock and reports recovery paths; restore the affected files before removing the lock and retrying. A stale installation lock otherwise requires inspecting the interrupted operation before removing it. Codex settings, MCP servers, permissions, and global instructions are not changed. Agents inherit model and reasoning settings; review-only instructions do not create a separate sandbox. Restart Codex if newly installed tools do not appear. + +Public discovery and custom-agent locations follow the official [skills](https://learn.chatgpt.com/docs/build-skills) and [subagent](https://learn.chatgpt.com/docs/agent-configuration/subagents) documentation. Use the Agentics installer for the complete bundled resources and role dependencies. + +### Using skills and maintenance workflows + +Invoke skills as `$change-plan`, `$pr-review`, or another name from `agentics list`. Only load the skills needed for the request. PR review remains explicitly invoked. The collection follows the target repository's root and scoped `AGENTS.md`, templates, and owning documentation; it imposes no required project configuration schema. + +```bash +agentics workflows list +agentics workflows show daily-bug-scan +``` + +Use `$maintenance-workflow` with a playbook ID to run a requested procedure. The playbooks include PR maintenance, error triage, bug scans, test gaps, documentation cohesion, product/marketing consistency, code health, and change digests. Installing or reading a playbook creates no schedules and grants no publication or deployment authority. Optional integrations must already be available and authorized. + +### Portable GitHub helpers + +```bash +agentics tools --repo-root /path/to/checkout pr-threads list-open-prs +agentics tools --repo-root /path/to/checkout pr-threads list-merged-prs +agentics tools --repo-root /path/to/checkout pr-threads list-unresolved-threads --pr 42 +agentics tools --repo-root /path/to/checkout preview --help +agentics tools --repo-root /path/to/checkout preview-sync --help +``` + +Helpers discover the current checkout through Git when `--repo-root` is omitted. An explicit root takes precedence over ambient repository environment variables; `--repo` explicitly selects the GitHub repository. Author filters use `--author` or the authenticated GitHub identity. The merged-PR command discovers the repository's default branch unless `--base` is supplied. Git and authenticated GitHub CLI are required for GitHub operations; preview publication requires GitHub.com and GitHub CLI 2.99+. + +Preview capture stays in the consuming project. Agentics owns artifact storage, capture provenance, hashes, native attachment publication, marked comments, and cleanup. Publishing requires the local and PR heads to match, and missing local captures leave published previews intact. Explicit deletion is separate from local cleanup. + +### Developing and consuming the collection + +Canonical sources are `skills/`, `agents/`, `workflows/`, and `references/`; `catalog.json` declares groups, dependencies, and resource assembly. The wheel bundles these sources, so installations work without the original checkout. Edit canonical sources and reinstall/update; do not edit installed copies. + +Project migration keeps application commands, label/body policies, operational caveats, and historical output markers in existing project docs. A project that calls Python helpers must install the toolkit in its own development/CI environment, pinned to an immutable Git revision or a hash-locked wheel. It must never depend on a developer's global Codex directory. A generated dependency wheel is an installation artifact, not a separately maintained source fork. + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install '.[test]' build +.venv/bin/agentics validate +.venv/bin/python -m unittest discover -s tests -v +.venv/bin/python -m build +``` + +The toolkit CI validates Python 3.11 and 3.14, catalog resources, installer ownership, and isolated wheel installation. Existing Claude commands and the pre-commit package remain separate subprojects. + ## Subprojects ### claude-commands @@ -92,7 +174,7 @@ gh aw compile # compile .md sources into .lock.yml | Workflow | Summary | Install | |----------|---------|---------| -| [Agents Doc Sync](#agents-doc-sync) | Keeps `CLAUDE.md` and `AGENTS.md` aligned; opens a PR only when sync changes are needed. | `gh aw add-wizard aaronspindler/agentic_workflows/agents-doc-sync` | +| [Agents Doc Sync](#agents-doc-sync) | Keeps `CLAUDE.md` and `AGENTS.md` aligned; opens a PR only when sync changes are needed. | `gh aw add-wizard aaronspindler/agentics/agents-doc-sync` | ### Agents Doc Sync @@ -109,6 +191,13 @@ Detects divergence between `CLAUDE.md` and `AGENTS.md`, updates the file missing ``` agentics/ +├── skills/ # Canonical portable Codex skills +├── agents/ # Custom roles with inherited model settings +├── workflows/ # Requested maintenance playbooks +├── references/ # Canonical resources bundled per skill +├── catalog.json # Groups, dependencies, and resources +├── src/agentics_tools/ # Installer and portable helper runtime +├── tests/ # Toolkit regression coverage ├── claude-commands/ # Claude Code slash commands (source of truth) │ ├── commands/ # /review, /refine, /suggest, /design, /daily, /ticket │ ├── shared/ # Shared config and templates diff --git a/agents/auditor.toml b/agents/auditor.toml new file mode 100644 index 0000000..c436434 --- /dev/null +++ b/agents/auditor.toml @@ -0,0 +1,9 @@ +name = "auditor" +description = "Read-only auditor for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Audit only the requested change for cohesion, duplicated logic, speculative abstractions, unnecessary indirection, partial pattern shifts, naming/placement, documentation drift, and app-local frontend fidelity. Read owning architecture/design guidance and nearby patterns. Judge behavior and maintenance cost rather than line counts. Preserve correctness, authorization, provenance, retained originals, accessibility, resource bounds, recovery, and meaningful tests. Return concrete AUDIT-NNN findings with severity/confidence, exact references, required versus optional follow-up, scope, and limits. Do not substitute a correctness review or implement cleanup. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/ci-explorer.toml b/agents/ci-explorer.toml new file mode 100644 index 0000000..c4783db --- /dev/null +++ b/agents/ci-explorer.toml @@ -0,0 +1,9 @@ +name = "ci_explorer" +description = "Read-only ci explorer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Inspect the exact requested Actions run, job, attempt, check, workflow, runner/cache, and dependency setup. Gather the first failing step and bounded logs/annotations, with IDs, timestamps, and file references. Distinguish code regressions from infrastructure noise and inaccessible or truncated evidence. Do not rerun workflows, publish comments, or implement fixes. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/design-peer-reviewer.toml b/agents/design-peer-reviewer.toml new file mode 100644 index 0000000..1cd836b --- /dev/null +++ b/agents/design-peer-reviewer.toml @@ -0,0 +1,9 @@ +name = "design_peer_reviewer" +description = "Read-only design peer reviewer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Review the requested diff, route, screenshot, or UI state for workflow clarity, hierarchy, density, copy, accessible interaction, meaningful user states, and fidelity to the touched app's own design system. Resolve audience and product vocabulary from project guidance. Separate concrete defects from subjective preferences or product choices. Return DESIGN-NNN findings with severity/confidence, exact references, smallest follow-up, reviewed scope, and limits. Do not create screenshots or change designs unless assigned a separate authorized task. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/docs-maintainer.toml b/agents/docs-maintainer.toml new file mode 100644 index 0000000..f0cd478 --- /dev/null +++ b/agents/docs-maintainer.toml @@ -0,0 +1,9 @@ +name = "docs_maintainer" +description = "Read-only docs maintainer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Compare requested human-facing docs with owning commands, config, tests, capability definitions, and routing. Detect factual drift, duplicated guidance, missing ownership, stale paths, execution-location errors, and overstated validation/health guarantees. Source docs are not proof of implementation or live state; do not probe live APIs merely to validate prose. Prefer one owning correction plus short pointers. Return DOC-NNN findings with target/source lines, impact, proposed correction, checks, scope, and unknowns. Do not edit docs. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/docs-researcher.toml b/agents/docs-researcher.toml new file mode 100644 index 0000000..a311d43 --- /dev/null +++ b/agents/docs-researcher.toml @@ -0,0 +1,9 @@ +name = "docs_researcher" +description = "Read-only docs researcher for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Verify requested external APIs, options, and version-specific behavior through primary vendor docs, specifications, or connected official documentation tools. Return concise answers with exact links, relevant version/date context, confidence, and missing evidence. Do not drift into internal-doc maintenance, code review, broad fix design, or implementation. Do not add a project-specific MCP endpoint or model pin. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/infrastructure-analyst.toml b/agents/infrastructure-analyst.toml new file mode 100644 index 0000000..e2f330f --- /dev/null +++ b/agents/infrastructure-analyst.toml @@ -0,0 +1,9 @@ +name = "infrastructure_analyst" +description = "Read-only infrastructure analyst for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Establish desired-state service/host identity, integration consumers, exact deployment scope, and artifact/workflow contract. Discover separate canonical owners for service addresses, management hosts, and physical hosts. Trace DNS, human endpoint catalogs, metrics/log/uptime wiring only where configured. Distinguish remote Git-ref deployment, local config sync, file transfer, and runtime overlay; unpublished local edits are not delivered by a fetched ref. Identify exact hosts, operation, prerequisites, environment references, convergence side effects, preflight, and postdeploy evidence. Return source-backed coupled changes and rollout contract without executing deployment or provisioning. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/observability-diagnostician.toml b/agents/observability-diagnostician.toml new file mode 100644 index 0000000..23ae707 --- /dev/null +++ b/agents/observability-diagnostician.toml @@ -0,0 +1,9 @@ +name = "observability_diagnostician" +description = "Read-only observability diagnostician for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Diagnose only the assigned services/time interval using fresh bounded metrics, logs, protocol checks, and host evidence. Discover available read-only telemetry tools/datasources; do not assume workstation-global access or internal port reachability. Inspect helper prerequisites for mutations. Bound queries by labels, time, limit, and sample step. Separate application availability, scrape/ingestion health, desired wiring, observed facts, hypotheses, impact, confidence, and gaps. Missing/stale series and absent configured logs do not prove health. Test the failing protocol directly. Do not install tools, reconfigure access, dashboards, alerts, monitors, or services. Return exact observation window/timezone, evidence, checks, and next discriminating probe. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/ops-explorer.toml b/agents/ops-explorer.toml new file mode 100644 index 0000000..a099ca7 --- /dev/null +++ b/agents/ops-explorer.toml @@ -0,0 +1,9 @@ +name = "ops_explorer" +description = "Read-only ops explorer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Gather only requested read-only provider, deployment, routing, health, resource, and error evidence against the target project's owning service contract. Correlate artifact SHAs, timestamps, service identities, application state, and bounded logs. For media services, protect direct upload URLs, private signing material, webhook secrets, and personal payloads. Separate confirmed drift, hypotheses, source expectations, and missing access. Do not propose or execute live recovery unless the parent requests operator options; options still do not authorize mutation. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/pr-explorer.toml b/agents/pr-explorer.toml new file mode 100644 index 0000000..052abd3 --- /dev/null +++ b/agents/pr-explorer.toml @@ -0,0 +1,9 @@ +name = "pr_explorer" +description = "Read-only pr explorer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Trace requested entrypoints, callers, configuration inputs, shared helpers, owning source, and relevant tests. Read root/scoped instructions and discover canonical inventory owners when host or service identity matters. Return an execution/ownership map with exact files/symbols, command locations, unresolved questions, and checks actually performed. Separate source facts from runtime observations. Do not judge correctness, propose fixes, or broaden scope unless the parent requests that boundary. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/reviewer.toml b/agents/reviewer.toml new file mode 100644 index 0000000..a712262 --- /dev/null +++ b/agents/reviewer.toml @@ -0,0 +1,9 @@ +name = "reviewer" +description = "Read-only reviewer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Review the requested final diff for concrete correctness, security, regression, deployment, and test-gap failures. Trace the trigger and consequence through owning source, config, shared helpers, and canonical identity maps when relevant. Use the smallest non-mutating check and inspect prerequisites. Respect public/private configuration policy; tracked operator environment files are not automatically exposure findings. Lead with stable REVIEW-NNN findings, priority/confidence, exact current-head file/line, trigger, impact, evidence, and smallest repair direction. State scope, observed checks, uncertainties, and residual gaps; no findings does not prove complete correctness. Leave structural/docs/design-only questions to their specialists. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/simplicity-reviewer.toml b/agents/simplicity-reviewer.toml new file mode 100644 index 0000000..c460a85 --- /dev/null +++ b/agents/simplicity-reviewer.toml @@ -0,0 +1,9 @@ +name = "simplicity_reviewer" +description = "Read-only review for unnecessary complexity while preserving required safeguards." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Review only the final assigned diff, including new files, for unnecessary complexity without a current requirement: speculative frameworks, abstractions, wrappers, indirection, configuration, dependencies, caches, duplicated logic, and excessive docs/test scaffolding. Read surrounding callers only as needed; exclude unrelated work and do not spawn agents. Prefer idiomatic project conventions and assess maintenance cost/behavior, not line count. Small explicit duplication can be clearer than premature abstraction. Preserve security, authorization, case/tenant isolation, immutable originals/provenance, accessibility, bounds, recovery, and meaningful tests. Return only actionable findings ordered by impact with exact file/line, unnecessary cost, smallest correct alternative, and safeguards to retain. Separate optional simplifications. If none, say "No actionable overengineering found." State scope/limits and actual checks. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/test-gap-explorer.toml b/agents/test-gap-explorer.toml new file mode 100644 index 0000000..4b936b7 --- /dev/null +++ b/agents/test-gap-explorer.toml @@ -0,0 +1,9 @@ +name = "test_gap_explorer" +description = "Read-only test gap explorer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Identify at most one concrete regression-coverage opportunity in the assigned diff, bug, or landed change. Trace existing tests and the behavior at risk. Prefer no finding over speculative global coverage work. Return why existing coverage misses it, smallest useful test type/location, repository-approved validation command and execution context, source references, and confidence. Do not write tests. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/agents/visual-qa-explorer.toml b/agents/visual-qa-explorer.toml new file mode 100644 index 0000000..9d71ddc --- /dev/null +++ b/agents/visual-qa-explorer.toml @@ -0,0 +1,9 @@ +name = "visual_qa_explorer" +description = "Read-only visual qa explorer for focused evidence and findings." +developer_instructions = """ +Follow the active repository instructions and the assigned task boundary. Inherit the caller's model and reasoning preferences. + +Collect browser-rendered evidence for the requested route, viewport, state, and interactions against app-local design guidance. Record visible defects, relevant console/network failures, exact source/route references, and unverified viewports/states. Separate subjective concerns from rendering or interaction defects. Screenshots may be captured as assigned local evidence when permitted, but do not update tracked snapshots, implement UI, or publish comments. + +Stay read-only across files and external systems, including Git, GitHub, services, dashboards, alerts, and credentials. Inspect helper side effects before checks; use non-mutating evidence only. Never reproduce credentials, private records, or raw secret-bearing logs. Return findings to the parent, which owns implementation and authorized publication. +""" diff --git a/catalog.json b/catalog.json new file mode 100644 index 0000000..94bcab3 --- /dev/null +++ b/catalog.json @@ -0,0 +1,440 @@ +{ + "schema_version": 1, + "version": "0.1.0", + "items": [ + { + "id": "skill:change-plan", + "kind": "skill", + "name": "change-plan", + "path": "skills/change-plan", + "group": "core", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:change-tests", + "kind": "skill", + "name": "change-tests", + "path": "skills/change-tests", + "group": "core", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:change-verify", + "kind": "skill", + "name": "change-verify", + "path": "skills/change-verify", + "group": "core", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:change-docs", + "kind": "skill", + "name": "change-docs", + "path": "skills/change-docs", + "group": "docs", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:runtime-probe", + "kind": "skill", + "name": "runtime-probe", + "path": "skills/runtime-probe", + "group": "core", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:cohesion-guard", + "kind": "skill", + "name": "cohesion-guard", + "path": "skills/cohesion-guard", + "group": "core", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:change-audit", + "kind": "skill", + "name": "change-audit", + "path": "skills/change-audit", + "group": "core", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:deploy-safety", + "kind": "skill", + "name": "deploy-safety", + "path": "skills/deploy-safety", + "group": "ops", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/infrastructure.md": "references/infrastructure.md" + } + }, + { + "id": "skill:service-integration", + "kind": "skill", + "name": "service-integration", + "path": "skills/service-integration", + "group": "ops", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/infrastructure.md": "references/infrastructure.md" + } + }, + { + "id": "skill:stack-rollout", + "kind": "skill", + "name": "stack-rollout", + "path": "skills/stack-rollout", + "group": "ops", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/infrastructure.md": "references/infrastructure.md" + } + }, + { + "id": "skill:observability-diagnosis", + "kind": "skill", + "name": "observability-diagnosis", + "path": "skills/observability-diagnosis", + "group": "ops", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/observability.md": "references/observability.md" + } + }, + { + "id": "skill:dokploy-ops", + "kind": "skill", + "name": "dokploy-ops", + "path": "skills/dokploy-ops", + "group": "ops", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/live-ops.md": "references/live-ops.md" + } + }, + { + "id": "skill:cloudflare-ops", + "kind": "skill", + "name": "cloudflare-ops", + "path": "skills/cloudflare-ops", + "group": "ops", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/live-ops.md": "references/live-ops.md" + } + }, + { + "id": "skill:docs-audit", + "kind": "skill", + "name": "docs-audit", + "path": "skills/docs-audit", + "group": "docs", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:docs-maintain", + "kind": "skill", + "name": "docs-maintain", + "path": "skills/docs-maintain", + "group": "docs", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:backlog-maintain", + "kind": "skill", + "name": "backlog-maintain", + "path": "skills/backlog-maintain", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:codex-maintain", + "kind": "skill", + "name": "codex-maintain", + "path": "skills/codex-maintain", + "group": "maintenance", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md" + } + }, + { + "id": "skill:pr-review", + "kind": "skill", + "name": "pr-review", + "path": "skills/pr-review", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md" + } + }, + { + "id": "skill:pr-refine", + "kind": "skill", + "name": "pr-refine", + "path": "skills/pr-refine", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md" + } + }, + { + "id": "skill:pr-comment", + "kind": "skill", + "name": "pr-comment", + "path": "skills/pr-comment", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md" + } + }, + { + "id": "skill:pr-conflicts", + "kind": "skill", + "name": "pr-conflicts", + "path": "skills/pr-conflicts", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md" + } + }, + { + "id": "skill:pr-handoff", + "kind": "skill", + "name": "pr-handoff", + "path": "skills/pr-handoff", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-metadata.md": "references/pr-metadata.md" + } + }, + { + "id": "skill:pr-metadata", + "kind": "skill", + "name": "pr-metadata", + "path": "skills/pr-metadata", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md", + "references/pr-metadata.md": "references/pr-metadata.md" + } + }, + { + "id": "skill:pr-preview", + "kind": "skill", + "name": "pr-preview", + "path": "skills/pr-preview", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md", + "references/preview.md": "references/preview.md" + } + }, + { + "id": "skill:pre-pr-gate", + "kind": "skill", + "name": "pre-pr-gate", + "path": "skills/pre-pr-gate", + "group": "github", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/pr-workflows.md": "references/pr-workflows.md", + "references/pr-metadata.md": "references/pr-metadata.md", + "references/preview.md": "references/preview.md" + } + }, + { + "id": "skill:maintenance-workflow", + "kind": "skill", + "name": "maintenance-workflow", + "path": "skills/maintenance-workflow", + "group": "maintenance", + "dependencies": [], + "resources": { + "references/repository-context.md": "references/repository-context.md", + "references/workflow-index.md": "references/workflow-index.md", + "references/automation-guidelines.md": "references/automation-guidelines.md", + "references/workflows/pr-guardian.md": "workflows/pr-guardian.md", + "references/workflows/pr-reviewer.md": "workflows/pr-reviewer.md", + "references/workflows/merged-pr-comment-sweep.md": "workflows/merged-pr-comment-sweep.md", + "references/workflows/sentry-daily-triage.md": "workflows/sentry-daily-triage.md", + "references/workflows/daily-bug-scan.md": "workflows/daily-bug-scan.md", + "references/workflows/test-gap-detection.md": "workflows/test-gap-detection.md", + "references/workflows/docs-cohesion-keeper.md": "workflows/docs-cohesion-keeper.md", + "references/workflows/code-health-scout.md": "workflows/code-health-scout.md", + "references/workflows/daily-changes-digest.md": "workflows/daily-changes-digest.md", + "references/workflows/orchestrator.md": "workflows/orchestrator.md", + "references/workflows/product-marketing-sync.md": "workflows/product-marketing-sync.md" + } + }, + { + "id": "agent:pr_explorer", + "kind": "agent", + "name": "pr_explorer", + "path": "agents/pr-explorer.toml", + "group": "github", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:ci_explorer", + "kind": "agent", + "name": "ci_explorer", + "path": "agents/ci-explorer.toml", + "group": "github", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:reviewer", + "kind": "agent", + "name": "reviewer", + "path": "agents/reviewer.toml", + "group": "core", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:auditor", + "kind": "agent", + "name": "auditor", + "path": "agents/auditor.toml", + "group": "core", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:design_peer_reviewer", + "kind": "agent", + "name": "design_peer_reviewer", + "path": "agents/design-peer-reviewer.toml", + "group": "core", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:test_gap_explorer", + "kind": "agent", + "name": "test_gap_explorer", + "path": "agents/test-gap-explorer.toml", + "group": "core", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:visual_qa_explorer", + "kind": "agent", + "name": "visual_qa_explorer", + "path": "agents/visual-qa-explorer.toml", + "group": "core", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:docs_maintainer", + "kind": "agent", + "name": "docs_maintainer", + "path": "agents/docs-maintainer.toml", + "group": "docs", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:docs_researcher", + "kind": "agent", + "name": "docs_researcher", + "path": "agents/docs-researcher.toml", + "group": "docs", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:ops_explorer", + "kind": "agent", + "name": "ops_explorer", + "path": "agents/ops-explorer.toml", + "group": "ops", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:infrastructure_analyst", + "kind": "agent", + "name": "infrastructure_analyst", + "path": "agents/infrastructure-analyst.toml", + "group": "ops", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:observability_diagnostician", + "kind": "agent", + "name": "observability_diagnostician", + "path": "agents/observability-diagnostician.toml", + "group": "ops", + "dependencies": [], + "resources": {} + }, + { + "id": "agent:simplicity_reviewer", + "kind": "agent", + "name": "simplicity_reviewer", + "path": "agents/simplicity-reviewer.toml", + "group": "core", + "dependencies": [], + "resources": {} + } + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4d2bea4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "agentics-toolkit" +version = "0.1.0" +description = "Reusable agent skills, review roles, maintenance workflows, and managed Codex installation." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +dependencies = ["requests"] + +[project.optional-dependencies] +test = ["PyYAML"] + +[project.scripts] +agentics = "agentics_tools.cli:main" + +[project.urls] +Homepage = "https://github.com/aaronspindler/agentics" + +[tool.hatch.build.targets.wheel] +packages = ["src/agentics_tools"] + +[tool.hatch.build.targets.wheel.force-include] +"catalog.json" = "agentics_tools/data/catalog.json" +"skills" = "agentics_tools/data/skills" +"agents" = "agentics_tools/data/agents" +"workflows" = "agentics_tools/data/workflows" +"references" = "agentics_tools/data/references" + +[tool.hatch.build.targets.sdist] +include = ["src/", "skills/", "agents/", "workflows/", "references/", "tests/", "catalog.json", "pyproject.toml", "README.md", "LICENSE"] diff --git a/references/automation-guidelines.md b/references/automation-guidelines.md new file mode 100644 index 0000000..2611b61 --- /dev/null +++ b/references/automation-guidelines.md @@ -0,0 +1,59 @@ +# Shared maintenance rules + +These rules apply to explicitly requested manual playbooks or a separately enabled scheduler. Installation creates no recurring job, permission grant, memory store, labels, or external artifacts. Read [repository context](repository-context.md), then only the selected lane. + +## Startup + +- Resolve repository, authorized identity/author scope, exact work item, owning docs, validation commands, target branches, and external integration coverage from the current project and request. Follow its shell/environment policies. +- Reuse user authorization already supplied; a lane's permitted operation describes its capability, not authority to perform it. Establish which file/Git/GitHub/memory/live operations are actually authorized before dependent writes. +- Manual runs use the current checkout unless the user requests isolation or the operation requires a safe worktree. Explicitly enabled unattended code changes use isolated branches/worktrees under their host contract. +- Verify authentication and intended repository before GitHub operations. Discover the repository's backlog owner, not a universal issue policy. Missing configuration/access blocks only dependent work. + +## Run Locks + +- Keep a JSON ownership record at the lane's lock path across the entire protected run. Before acquisition, obtain a unique run token and a host-verifiable owner identity: the specific scheduler run/turn ID, or a dedicated agent PID plus process-start identity. A recurring task ID, shared app-server PID, transient tool-shell PID, or lock age is not sufficient owner evidence. If lifecycle verification is unavailable, report a blocker before acquiring. +- Serialize ownership checks, acquisition, recovery, and release with a short-lived Python `fcntl.flock(LOCK_EX | LOCK_NB)` on `.guard`. Keep that guard file in place; never unlink or replace its inode. Release the advisory guard after each ownership operation; the JSON record retains ownership between agent tool calls. +- Under the guard, read the current record and publish a complete new record only when the path is absent or the exact previous owner is proven terminated with its protected calls drained. Write a unique temporary sibling file, then publish it with atomic `os.replace`; never create an empty lock directory or partially write the published record. Begin protected work only after publication succeeds. A crash before publication leaves no new owner; a crash afterward leaves a complete record that the next run can reclaim. +- A busy guard or a confirmed active owner means another run holds the lock: stop without error. Reclaim a terminated owner's record automatically under the same guard, distinguishing PID reuse by process-start identity. Unknown lifecycle state, legacy directories, malformed records, and permission errors are blockers; leave them untouched and report the missing evidence. Age alone must never authorize reclamation. +- Before each protected operation, verify the record still matches this run's token and owner identity; stop protected work if ownership is lost. At completion, acquire the guard, re-read the record, and remove it only if both still match this run. This prevents delayed cleanup or competing recovery attempts from deleting a successor's lock. + + +Scope every lock by canonical repository identity and lane; unrelated repositories must not share a global lane lock. Use a host-equivalent advisory-lock primitive if `fcntl` is unavailable, preserving ownership and atomic publication semantics. If lifecycle ownership cannot be verified, report the limitation rather than weakening reclamation rules. + +## Durable Memory + +- Use the durable deduplication/state backend established by repository guidance or the active scheduler contract. Do not choose or create an external memory store or silently switch backends. If the selected lane requires persistent deduplication and none is configured, finish read-only preparation and report that blocker before duplicate-prone writes. +- GitHub Discussions is one supported adapter, not a universal requirement. When selected, resolve repository/category/title/marker from owning guidance. The lane's suggested discussion names/markers are defaults only after that adapter is explicitly chosen. Preserve existing compatibility and legacy aliases locally. +- Read the active state and relevant historical input before selecting work. For standalone runs use the configured lane state; for orchestrated runs central state is the sole active write surface and historical lane state is read-only input. +- Record work item key, head/base or source snapshot, active worker ID, branch/worktree, observed outcome/no-op, blockers, artifact links, cooldown, and next eligible run. Preserve enough information to suppress repeats on unchanged evidence. +- Dispatcher owns one ordered state-write queue. Workers return outcomes for their assigned item; do not rewrite unrelated state or central memory. Required external output artifacts remain separate from orchestration state. +- Stop duplicate-prone dispatch/writes when required state cannot be read or updated safely. Do not substitute local files, another account, or a new discussion unless that backend change is explicitly authorized. + +## Delegation + +- Dispatcher playbooks dispatch work rather than implementing lane-specific fixes/reviews inline. Use one bounded worker per PR, issue, incident group, drift, test gap, digest date, or debt item. Helpers stay inside their assigned item. +- Read the active host's actual worker cap; fail closed to one worker if no cap is available. Do not invent a model/reasoning pin. Shared agents inherit caller preferences. +- Verify worker tooling before dispatch. If a dispatcher requires separate workers and tooling is absent, report the blocker instead of silently substituting inline execution. +- Record each worker's ID, lane, assigned scope/key, source snapshot, branch/path, and state before launching duplicate-prone work. Pass a self-contained prompt with permitted outputs, validation, ownership, and authorization boundaries. +- Budgets/cadences in lane descriptions are defaults for a separately configured run; use repository/user overrides. Exhausting one lane budget blocks that lane, not unrelated eligible work. + +## Git And Worktrees + +- Carry forward the user/host contract's specific write authority. A manual playbook invocation is not blanket permission for every operation the lane can perform; isolation does not grant publication authority. +- Preserve unrelated checkout content. Unattended code workers start from the selected current default-branch ref; PR workers use their assigned PR head. The dispatcher checkout is not their implementation workspace. +- Never merge a PR, force-push, rebase, or mutate live systems under a maintenance playbook. A single inspected base-into-head merge is limited to explicitly authorized PR-conflict repair. +- Publish only scoped, validated changes to the verified target. When evidence, product intent, compatibility, or validation is unclear, return a concrete blocker or prepare a deduplicated issue under existing issue-write authority. + +## Validation And Output + +- Use the target repository's approved checks/environment; preserve real-stack, native validator, interpreter, and coverage requirements. Distinguish static checks, behavior, coverage, hosted CI, artifact identity, and live health. +- Follow repository PR metadata/body policy, existing labels, readiness gates, and issue templates. This library imposes no universal validation-section policy, readiness label, or product-label taxonomy. +- Preserve configured hidden markers, memory identity, output prefixes, and date keys as compatibility contracts. Update existing artifacts when evidence changes rather than creating duplicates. Project guidance may select the lane's suggested output schema. +- After publication take one check snapshot and hand off pending CI. Poll only when monitoring is requested or investigating an existing failure. +- Keep external output concise, source-backed, and redacted. Never expose credentials, private customer data, unnecessary raw logs, request/session identifiers, or full stack traces. + +## Orchestrator Overlay + +Use the optional [orchestrator](workflows/orchestrator.md) for route coverage, priorities, worker dispatch, budgets, and central state only when that dispatcher is requested/configured. Enable only lanes with the required project policy, integrations, and write authority. The [workflow index](workflow-index.md) is a catalog, not a scheduler. + +Readiness labels are optional repository policy. If one is configured, [PR Guardian's readiness gate](workflows/pr-guardian.md#ready-for-human-label-gate) defines the evidence needed for its compatible GitHub adapter; preserve any existing local contract. diff --git a/references/infrastructure.md b/references/infrastructure.md new file mode 100644 index 0000000..00e558c --- /dev/null +++ b/references/infrastructure.md @@ -0,0 +1,11 @@ +# Infrastructure integration and rollout + +Resolve service identity, management-host identity, and physical-host inventory from their distinct canonical owners. Adding a service to an existing host does not imply provisioning a host. Tracked SSH configuration does not prove credentials or connectivity. + +Trace applicable consumers: runtime config/Compose, host defaults and profiles, DNS, human-facing endpoint catalog, metrics discovery, log shipping, uptime checks, status pages, and docs. Do not invent a web UI or telemetry endpoint for a headless service. External public routing may have a separate owner. Retirement must preserve shared hosts and consumers still in use. + +For rollout, bind the exact stack, host set, operation, environment overlay, and artifact source. Distinguish remote Git-ref deployment, local file sync, local transfer, and runtime overlays. Unpublished local edits are absent from a helper that fetches a remote Git ref; do not silently substitute another ref or publish them. Inspect reconciliation effects and the complete desired-state diff, including deletion behavior. + +Run native preflight before mutation. Provisioning, bootstrap, reboots, broad fleet targets, forced recreation, rollback, and ordinary deployment are distinct operations. Preserve native transport retries without adding a new mutation loop. After failure, report partial application and gather read-only evidence before proposing another operation. + +Verify the selected artifact and service behavior on the actual target. Workstation-local process/Compose state cannot verify a remote workload. Static validators, behavioral tests, coverage, hosted CI, artifact identity, and protocol-level health provide separate evidence; report each honestly. diff --git a/references/live-ops.md b/references/live-ops.md new file mode 100644 index 0000000..83f8d86 --- /dev/null +++ b/references/live-ops.md @@ -0,0 +1,11 @@ +# Read-first operations + +Read [repository context](repository-context.md), then the affected service's current deployment and operator contract. Verify service/host identity, artifact source, expected routing, and actual execution location. Use provider reads, deployment records, health checks, and bounded telemetry before proposing recovery. + +Separate source expectations, timestamped live facts, and hypotheses. Return exact service/route identifiers and SHAs when useful, while redacting credentials, private payloads, upload URLs, signing keys, and unrelated personal data. A missing metric or unavailable provider read is an evidence gap, not proof of health or outage. + +Inspect helper prerequisites: commands named status/logs may still change local permissions, sync config, or reconcile services. In a read-only investigation, use established non-mutating transport or report the blocked check. + +Carry out live mutation only when covered by the user's instruction or active lane contract. Reuse existing authorization; finish concrete preparation before asking for a missing action. Prefer one narrow recovery, verify its result, and stop on failure before changing the mutation strategy. Do not add automatic redeploy, force-recreation, rollback, provisioning, maintenance, or reboot loops. + +Separate operator recovery from source changes and rollout planning. Apply schema changes through the target project's compatibility and expand/contract rules; never treat transient restoration as proof that the code or deployment contract is repaired. diff --git a/references/observability.md b/references/observability.md new file mode 100644 index 0000000..1eb2084 --- /dev/null +++ b/references/observability.md @@ -0,0 +1,9 @@ +# Bounded observability + +Identify service/host and absolute time range/timezone. Start with the last hour for an unspecified active incident, or the last 24 hours for an unspecified daily review. Narrow as evidence allows. + +Discover available read-only telemetry tools and datasource identity. Do not install tools or reconfigure MCP, credentials, dashboards, datasources, alerts, or monitors to obtain evidence without separate authorization. Internal service ports do not imply workstation reachability; use established read-only transport when necessary. + +Check telemetry freshness, label coverage, query scope, and configured ingestion before inferring health. Bound queries by service/host, time interval, result limit, and sample step. Missing series, stale samples, or absent logs do not establish health. Separate application availability, exporter scrape health, and log ingestion. + +Test the failing protocol directly: HTTP uptime does not establish DNS resolution or another protocol's functionality. Correlate relevant metrics and bounded logs only where configured. Distinguish desired source wiring, observed facts, hypotheses, impact, confidence, competing causes, and the next discriminating probe. Redact credentials and private payloads. diff --git a/references/pr-metadata.md b/references/pr-metadata.md new file mode 100644 index 0000000..112cf76 --- /dev/null +++ b/references/pr-metadata.md @@ -0,0 +1,11 @@ +# PR handoff and metadata + +Use the target repository's PR template and documented title/body/label conventions. Keep personal or product-specific formatting policy in its owning documentation. If no format is specified, lead with the concrete problem and resulting behavior, then concise relevant validation and material limitations. Use a short sentence-case title; scale detail to the change. + +Gather the final branch/base/head, actual diff, operator/user impact, exact issue links, observed validation, and published preview links. Describe the final implementation for a reviewer without conversation context. Omit private memory citations, secret values, abandoned approaches, speculative claims, and local artifact paths. + +Use closing keywords only when the change completes an issue; otherwise reference it, preserving cross-repository qualification. Use existing repository labels only, with path/product mappings only when the target project defines them. Do not create a new label taxonomy during metadata normalization or imply auto-merge permission. + +Include preview links only after native attachments or the marked preview comment exist. Create a new PR without placeholders, then update its metadata after authorized preview publication. Preview synchronization itself does not edit the PR body. + +Prepare exact title, body, and labels before an authorized metadata write. Keep validation in the operator handoff when the project's PR-body policy excludes it. Drafting a handoff does not authorize Git/GitHub writes. diff --git a/references/pr-workflows.md b/references/pr-workflows.md new file mode 100644 index 0000000..186ac56 --- /dev/null +++ b/references/pr-workflows.md @@ -0,0 +1,17 @@ +# PR workflow foundations + +Read [repository context](repository-context.md). Accept a full GitHub PR URL, `OWNER/REPO#NUMBER`, or a number resolved against the verified current repository. Without an argument, use the current branch's PR or the requested local diff. Do not guess the default branch or silently switch repositories. + +Prefer connected GitHub tools for metadata, diffs, comments, and checks; use `gh` for exact Actions logs or connector gaps. The shared helper is `agentics tools --repo-root PATH pr-threads`: `list-open-prs`, `list-merged-prs`, `list-unresolved-threads`, and `reply-thread`. Supply configured author filters explicitly for author-scoped maintenance. Resolve safe checkout/worktree behavior from active repository guidance and the user's request. + +Review, comment publication, branch fixes, conflict repair, metadata, and live operations have separate scopes. Carry forward authorization already supplied for the requested action; a worktree is isolation, not permission. Do not force-push or rebase. Base-into-head merging is limited to an explicitly authorized conflict-repair workflow after inspecting live refs and conflicts. Preserve unrelated changes and do not publish unvalidated fixes. + +Use the relevant change skills when installed, or perform their equivalent focused preparation and validation. Named read-only specialists are optional: use a parent pass when delegation is unavailable. Shared agents inherit the caller's model and reasoning preferences; do not introduce a model pin through the delegated prompt. + +## Gate proof + +A `pre-pr-gate: ` marker records a completed gate on committed content. Include the reviewed base ref/SHA and checked inputs/results: commands, config, dependency/runtime state, and material evidence. After an authorized push, record the remote repository, branch ref, and verified remote head SHA. + +Before reusing proof, re-read remote branch and PR heads, resolve the current intended base, and compare local HEAD and checked inputs. Any missing evidence, changed base/input, relevant untracked/dirty content, or changed remote head invalidates affected evidence; the head SHA alone is insufficient. Refresh affected reviews/checks and reuse still-valid successful checks. Do not rerun the entire gate solely because another PR skill was invoked. + +After publication, take one check snapshot for handoff. Monitor or poll CI only when requested or when diagnosing an existing failed check. Pending checks alone do not require keeping a worker active. diff --git a/references/preview.md b/references/preview.md new file mode 100644 index 0000000..b9eb7e1 --- /dev/null +++ b/references/preview.md @@ -0,0 +1,11 @@ +# PR preview contract + +Capture uses the target repository's deterministic browser/full-stack adapter, route registry, setup, and validation policy. Do not copy another project's seeds or test harness. Select relevant before/after desktop/mobile states when feasible; a single current-state preview is valid when that is all the task supports. + +Use `agentics tools --repo-root PATH preview` for `id`, `path`, `dir`, `list`, `snapshot`, `record`, and `clean`. Take `snapshot` before rendering and pass it as `--source-snapshot` to `record` after the PNGs exist. The helper must reject source changes during capture. Keep artifacts, `captures.json`, and `attachments.json` under the helper-resolved ignored branch directory. Never commit preview PNGs. + +Publish with `agentics tools --repo-root PATH preview-sync --repo OWNER/REPO --pr NUMBER`, adding `--branch` when needed. Native publication supports GitHub.com, requires supported user authentication, repository write access, GitHub CLI 2.99.0+, and regular PNG files at most 10 MiB each. Inspect prerequisites without exposing tokens. + +Local HEAD must match the PR head before publication and remain unchanged through writes. Use one `` comment owned by the active account; block foreign-owned markers and consolidate owned duplicates. Cache unchanged uploads by content hash and repository numeric identity, retain partial upload progress, and bind each published image to verified capture provenance or explicitly report unavailable provenance. + +Fetch published attachment/comment URLs before updating PR metadata. Do not publish repository blob/raw PNG URLs. Local `preview clean` preserves uploaded attachments and comments. Missing captures are not a deletion request. Only explicit `preview-sync --delete` removes the active account's marked comment; attachments remain. diff --git a/references/repository-context.md b/references/repository-context.md new file mode 100644 index 0000000..02a487f --- /dev/null +++ b/references/repository-context.md @@ -0,0 +1,15 @@ +# Repository context + +Read the active checkout's `AGENTS.md` and applicable scoped instructions. Follow their authoritative documentation links and owning repository documentation; this library does not require a particular docs directory, build system, or hosting stack. Reuse unchanged context already read in the session. + +Identify the actual repository, branch, base, diff, runtime, working directory, and affected consumers. Read canonical service/host inventories separately when identity matters. Tracked desired state, passing static validation, deployed artifact identity, and live functional health establish different facts. + +Discover commands from owning manifests, Make targets, task runners, and existing tests. Inspect prerequisites and side effects before running a purported read-only command. Follow the repository's interpreter, environment, container, full-stack testing, and lint policies; do not install ambient dependencies or substitute host commands for required containers. + +User authorization and preferences persist. Carry out the authorized task without asking again at each skill boundary. Installing or reading a skill grants no additional permission. Preserve unrelated changes. Read-only roles do not edit files or mutate any external state, including through helper prerequisites. Planning, diagnosis, validation, draft handoffs, and local configuration work do not by themselves authorize deployment or publication. + +Keep project-specific commands, paths, authors, timezone, labels, issue/PR templates, release targets, credentials references, and external project identifiers in the target repository's owning guidance. Missing configuration blocks only actions that depend on it. Complete independent preparation and describe the exact missing fact; do not guess deployment targets, broaden authors, or fall back to a different external system. + +Never reproduce credentials, private records, raw secret-bearing logs, or historical private agent memory. Respect the target repository's public/private configuration policy rather than declaring every tracked environment file a defect. Preserve required authorization, provenance, retained originals, accessibility, resource bounds, and recoverable failures when applicable. + +Named skills and specialist roles are optional routing aids unless the target task explicitly requires one. If a referenced capability is not installed, the parent may perform the equivalent scoped preparation, evidence gathering, or validation while preserving its read-only and authorization boundaries. Do not invent an unavailable tool or broaden the task to install it. diff --git a/references/workflow-index.md b/references/workflow-index.md new file mode 100644 index 0000000..fd1894e --- /dev/null +++ b/references/workflow-index.md @@ -0,0 +1,17 @@ +# Maintenance workflow index + +These playbooks support explicitly requested manual work. Installation creates no schedule and grants no outbound-write authority. Read [shared maintenance rules](automation-guidelines.md), then only the selected playbook; resolve project-specific policy from its owning documentation. + +| Workflow | Use | +| --- | --- | +| [pr-guardian](workflows/pr-guardian.md) | Stabilize owned same-repository PRs: conflicts, existing CI failures, actionable feedback, readiness. | +| [pr-reviewer](workflows/pr-reviewer.md) | Review open PR heads with deduplicated findings; no code changes. | +| [merged-pr-comment-sweep](workflows/merged-pr-comment-sweep.md) | Convert unresolved merged-PR feedback into bounded fixes or deduplicated issues. | +| [sentry-daily-triage](workflows/sentry-daily-triage.md) | Group complete configured project scans into actionable GitHub issues. | +| [daily-bug-scan](workflows/daily-bug-scan.md) | Investigate high-confidence landed regressions outside active PR maintenance. | +| [test-gap-detection](workflows/test-gap-detection.md) | Add one focused regression-test opportunity outside active PR stabilization. | +| [docs-cohesion-keeper](workflows/docs-cohesion-keeper.md) | Maintain source-backed internal documentation in bounded docs changes. | +| [code-health-scout](workflows/code-health-scout.md) | Combine complexity, cleanup, and code-hygiene signals into deduplicated debt issues. | +| [daily-changes-digest](workflows/daily-changes-digest.md) | Summarize a configured calendar day into one dated discussion. | +| [orchestrator](workflows/orchestrator.md) | Optionally dispatch bounded work across configured lanes without executing fixes inline. | +| [product-marketing-sync](workflows/product-marketing-sync.md) | Align allowed marketing surfaces with verified shipped product behavior. | diff --git a/skills/backlog-maintain/SKILL.md b/skills/backlog-maintain/SKILL.md new file mode 100644 index 0000000..a81cafa --- /dev/null +++ b/skills/backlog-maintain/SKILL.md @@ -0,0 +1,12 @@ +--- +name: backlog-maintain +description: "Audit and maintain GitHub issue boundaries, milestones, project fields, and deduplication without inventing speculative backlog work." +--- + +# Backlog Maintain + +1. Read [repository context](references/repository-context.md), discover the target project's backlog owner and issue templates, and resolve repository/project scope. +2. Search open, closed, and related issue families before writing. Reuse or refine existing issues before creating another; keep one concrete problem/outcome per issue. +3. Follow existing title/body templates and use project/issue fields and logical milestones for grouping. Do not invent labels where existing fields suffice. +4. Require evidence for each gap. Keep durable architecture/workflow docs distinct from issue-owned work queues when that is project policy. +5. Prepare exact proposed changes and perform only GitHub writes covered by current authorization. Report created, updated, reused, and skipped issues and any missing decisions. diff --git a/skills/backlog-maintain/agents/openai.yaml b/skills/backlog-maintain/agents/openai.yaml new file mode 100644 index 0000000..4fbc2ec --- /dev/null +++ b/skills/backlog-maintain/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Backlog Maintain" + short_description: "Maintain GitHub issue backlog boundaries" + default_prompt: "Use $backlog-maintain to reconcile and maintain the GitHub issue backlog." +policy: + allow_implicit_invocation: false diff --git a/skills/change-audit/SKILL.md b/skills/change-audit/SKILL.md new file mode 100644 index 0000000..4f5a3fc --- /dev/null +++ b/skills/change-audit/SKILL.md @@ -0,0 +1,12 @@ +--- +name: change-audit +description: "Audit substantial changes read-only for cohesion, duplicated abstractions, documentation drift, and fidelity to the touched app's patterns." +--- + +# Change Audit + +1. Read [repository context](references/repository-context.md), resolve the requested final diff/file set, and avoid unrelated existing work. Skip this audit when the change has no meaningful structural or interface surface. +2. Inspect relevant architecture and design owners and nearby components. Check partial pattern shifts, duplicate helpers, speculative frameworks, dead scaffolding, placement/naming, docs drift, and app-local frontend fidelity. +3. Preserve required security, authorization, retained data/provenance, accessibility, bounds, recovery, and meaningful tests; these safeguards are not bloat. Judge maintenance cost and behavior, not line count. +4. Return findings first with stable `AUDIT-NNN` IDs, severity/confidence, exact file/line, cost/impact, and smallest follow-up. Separate material defects from optional simplifications; say when no actionable issue exists. +5. Stay read-only across files, Git/GitHub, services, dashboards, and credentials. Do not run fixing hooks or drift into correctness-first review or implementation. diff --git a/skills/change-audit/agents/openai.yaml b/skills/change-audit/agents/openai.yaml new file mode 100644 index 0000000..9a4aebf --- /dev/null +++ b/skills/change-audit/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Change Audit" + short_description: "Read-only audit for cohesion, docs, and frontend drift" + default_prompt: "Use $change-audit to audit this change for cohesion, drift, and unnecessary complexity." +policy: + allow_implicit_invocation: true diff --git a/skills/change-docs/SKILL.md b/skills/change-docs/SKILL.md new file mode 100644 index 0000000..275c0d1 --- /dev/null +++ b/skills/change-docs/SKILL.md @@ -0,0 +1,12 @@ +--- +name: change-docs +description: "Update canonical documentation when the current implementation changes behavior, architecture, operator commands, or agent workflows." +--- + +# Change Docs + +1. Read [repository context](references/repository-context.md) and decide whether the change makes existing instructions misleading. Routine internal fixes may need no documentation. +2. Discover the existing owner: root/scoped instructions, user overview, architecture, app/stack operator guide, or capability definition. Edit the owner in place and keep routers concise. +3. Verify commands against their Make/task/script implementation, including working directory, interpreter, local/remote execution, artifact source, prerequisites, side effects, and actual automatic hooks. +4. Remove contradictory or unsupported claims rather than layering caveats. Do not change implementation, recreate removed automation, or deploy simply to match stale prose. Keep mutable inventory facts in their canonical owner. +5. Validate changed docs/metadata as appropriate and report corrected behavior and evidence. Broader documentation audits/maintenance remain separate from branch-local documentation sync. diff --git a/skills/change-docs/agents/openai.yaml b/skills/change-docs/agents/openai.yaml new file mode 100644 index 0000000..e2c77c1 --- /dev/null +++ b/skills/change-docs/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Change Docs" + short_description: "Update the canonical docs that own the changed behavior" + default_prompt: "Use $change-docs to update the canonical documentation for this change." +policy: + allow_implicit_invocation: true diff --git a/skills/change-plan/SKILL.md b/skills/change-plan/SKILL.md new file mode 100644 index 0000000..ff1e980 --- /dev/null +++ b/skills/change-plan/SKILL.md @@ -0,0 +1,12 @@ +--- +name: change-plan +description: "Choose a complete implementation boundary before changing behavior, interfaces, persisted state, service wiring, or operator workflows." +--- + +# Change Plan + +1. Read [repository context](references/repository-context.md) and trace entrypoints, callers, configuration consumers, and owning guidance before editing. +2. Classify the compatibility surface: internal helper, public API/route, template and browser code, serialized state/schema, deployment, or workflow. Update coupled layers and references together. +3. Prefer the simplest complete change over speculative frameworks, duplicate helpers, aliases, or shims. Preserve real released contracts, data, required safeguards, and rollback/recovery needs; use compatibility stages where they are necessary. +4. For infrastructure identity changes, include existing DNS, endpoint, telemetry, and host consumers. For destructive migrations, separate runtime removal from incompatible schema removal across releases when older code can still run. +5. State the selected boundary, coupled edits, compatibility/rollout impact, and smallest meaningful validation. Planning does not authorize publication or deployment. Use runtime investigation first when the failure path is uncertain. diff --git a/skills/change-plan/agents/openai.yaml b/skills/change-plan/agents/openai.yaml new file mode 100644 index 0000000..18bf6fc --- /dev/null +++ b/skills/change-plan/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Change Plan" + short_description: "Choose the safest implementation boundary before editing" + default_prompt: "Use $change-plan to choose a safe implementation boundary before editing." +policy: + allow_implicit_invocation: true diff --git a/skills/change-tests/SKILL.md b/skills/change-tests/SKILL.md new file mode 100644 index 0000000..de9c827 --- /dev/null +++ b/skills/change-tests/SKILL.md @@ -0,0 +1,12 @@ +--- +name: change-tests +description: "Add focused regression coverage for changed behavior or a concrete test gap, using the target repository's test framework." +--- + +# Change Tests + +1. Read [repository context](references/repository-context.md). Start from the actual diff, trigger, and neighboring tests; identify the behavior or integration seam at risk. +2. Add the narrowest stable test that fails for the bug or protects the changed contract. Assert meaningful outcomes rather than implementation wording; preserve unique existing assertions even when setup resembles another test. +3. Use deterministic unit fixtures for remote APIs and transport. Use the repository's real full-stack policy for user-flow coverage; do not substitute mocked backends where the project requires integration evidence. +4. Configuration-only changes may need native validators instead of invented test scaffolding. Do not expand into unrelated coverage campaigns. +5. Run focused tests through the approved interpreter/container workflow. Explain targeted-test results separately from full-suite coverage; overridden coverage options do not establish the full gate. Broaden checks only for coupled paths or unresolved failures. diff --git a/skills/change-tests/agents/openai.yaml b/skills/change-tests/agents/openai.yaml new file mode 100644 index 0000000..ba5b518 --- /dev/null +++ b/skills/change-tests/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Change Tests" + short_description: "Add focused tests around the changed behavior" + default_prompt: "Use $change-tests to add focused regression coverage for the changed behavior." +policy: + allow_implicit_invocation: true diff --git a/skills/change-verify/SKILL.md b/skills/change-verify/SKILL.md new file mode 100644 index 0000000..91bc75a --- /dev/null +++ b/skills/change-verify/SKILL.md @@ -0,0 +1,12 @@ +--- +name: change-verify +description: "Choose and run the smallest repository-approved checks after implementation, tests, documentation, skills, configuration, or workflow changes." +--- + +# Change Verify + +1. Read [repository context](references/repository-context.md), inspect changed files and owning manifests/targets, and classify docs, config, runtime, migration, frontend, and build effects. +2. Run required file-scoped lint and native validation. Hooks can rewrite files: respect the task's mutation boundary, inspect resulting changes, and rerun affected checks. Use all-file checks when shared lint configuration changed. +3. Follow project commands, execution location, environment, container, and full-stack rules. Use migration-graph/schema checks for migration edits and relevant asset builds when their inputs change. +4. Reuse green evidence only while checked files, config, dependencies, runtime, and other material inputs remain unchanged. Expand for new changes, failures, or unresolved concerns; avoid repeating unrelated green suites. +5. Report exact commands, context, pass/fail/skip results, and limits. Distinguish static validation, behavior, coverage, CI, deployed identity, and live health. Verification alone does not authorize deployment or Git/GitHub publication. diff --git a/skills/change-verify/agents/openai.yaml b/skills/change-verify/agents/openai.yaml new file mode 100644 index 0000000..d267a9e --- /dev/null +++ b/skills/change-verify/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Change Verify" + short_description: "Run the required lint and repo-approved validation stack" + default_prompt: "Use $change-verify to select and run the required validation for this change." +policy: + allow_implicit_invocation: true diff --git a/skills/cloudflare-ops/SKILL.md b/skills/cloudflare-ops/SKILL.md new file mode 100644 index 0000000..2906760 --- /dev/null +++ b/skills/cloudflare-ops/SKILL.md @@ -0,0 +1,14 @@ +--- +name: cloudflare-ops +description: "Inspect Cloudflare Worker, Stream, routes, DNS, certificates, Access, and related provider state before scoped authorized recovery." +--- + +# Cloudflare Ops + +Read [repository context](references/repository-context.md) and [read-first operations](references/live-ops.md). + +1. Resolve the affected provider surface and expected ownership from project guidance. Inspect only relevant Workers, routes/domains, DNS/certificates, Access, Stream, Images, Email, analytics, or AI configuration. +2. Correlate provider deployment/artifact identity and GitHub release records with live routing and application evidence. Do not impose another project's domain or API ownership. +3. For Stream, correlate video status/UID, storage/delivery, webhook configuration, and signing-key metadata with application state and bounded logs. Never return direct upload URLs, signing private keys, webhook secrets, or tokens. Preserve documented upload/body routing contracts. +4. Separate confirmed drift, suspected drift, provider recovery, and code/workflow fixes. Apply only the smallest currently authorized recovery; installation or inspection grants no write authority. +5. Verify the result and stop after failure before changing strategy. Use rollout planning for compatibility-sensitive follow-up and update the canonical operator doc when source truth changed. diff --git a/skills/cloudflare-ops/agents/openai.yaml b/skills/cloudflare-ops/agents/openai.yaml new file mode 100644 index 0000000..ca977ac --- /dev/null +++ b/skills/cloudflare-ops/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Cloudflare Ops" + short_description: "Inspect Cloudflare state and guide safe recovery" + default_prompt: "Use $cloudflare-ops to inspect Cloudflare state and plan a safe recovery." +policy: + allow_implicit_invocation: false diff --git a/skills/codex-maintain/SKILL.md b/skills/codex-maintain/SKILL.md new file mode 100644 index 0000000..9d93933 --- /dev/null +++ b/skills/codex-maintain/SKILL.md @@ -0,0 +1,12 @@ +--- +name: codex-maintain +description: "Maintain shared or repository-local Codex skills, roles, workflow playbooks, installation metadata, and capability routing consistently." +--- + +# Codex Maintain + +1. Read [repository context](references/repository-context.md), identify the owning source catalog/installer or local capability directory, and inspect consumers before editing shared resources. +2. Keep reusable behavior in its canonical skill/reference/role/workflow, and project paths/commands/IDs in target-repo guidance. Preserve installed provenance, managed-file ownership, and explicit update behavior; never edit generated managed copies as source. +3. Keep skill folder/frontmatter names, UI prompts/policies, resource mappings, catalog dependencies, and routers consistent. Preserve invocation policies; do not infer explicit-only selection from sensitive operations. +4. Shared read-only agents inherit caller model/reasoning and permissions while their instructions preserve read-only behavior. Do not import another machine's sandbox roots, MCP endpoints, credentials, or model pins. +5. Validate YAML/TOML/JSON, links, installed-resource closure, catalog selection, and relevant installer behavior. Removed schedulers stay removed: installation and playbook maintenance do not enable recurring jobs or outbound writes. diff --git a/skills/codex-maintain/agents/openai.yaml b/skills/codex-maintain/agents/openai.yaml new file mode 100644 index 0000000..aa7c53c --- /dev/null +++ b/skills/codex-maintain/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Codex Maintain" + short_description: "Maintain repo Codex capabilities" + default_prompt: "Use $codex-maintain to update the repository's Codex capabilities consistently." +policy: + allow_implicit_invocation: false diff --git a/skills/cohesion-guard/SKILL.md b/skills/cohesion-guard/SKILL.md new file mode 100644 index 0000000..32b69d0 --- /dev/null +++ b/skills/cohesion-guard/SKILL.md @@ -0,0 +1,12 @@ +--- +name: cohesion-guard +description: "Keep feature, interface, and reusable component changes cohesive with the target application's established behavior and design." +--- + +# Cohesion Guard + +1. Read [repository context](references/repository-context.md), resolve touched apps/routes/components/APIs and meaningful user states, and inspect relevant design guidance plus nearby implementations. +2. Trace existing shared helpers/components before adding another path. Reuse established patterns where they fit; do not introduce a general abstraction without current consumers. +3. Keep loading, empty, error, permission, success, and narrow-viewport states consistent across coupled surfaces. Check naming, validation, navigation, accessible interaction, and product copy together. +4. Preserve each app's own design system rather than enforcing visual uniformity across unrelated products. Keep implementation commentary out of user-facing product copy unless it helps a product decision. +5. Maintain one complete implementation path and update consumers together. For trivial non-interface edits, skip this guard. Use a read-only final audit for substantial changes instead of broadening implementation scope. diff --git a/skills/cohesion-guard/agents/openai.yaml b/skills/cohesion-guard/agents/openai.yaml new file mode 100644 index 0000000..d011523 --- /dev/null +++ b/skills/cohesion-guard/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Cohesion Guard" + short_description: "Guard feature cohesion and reuse" + default_prompt: "Use $cohesion-guard to keep this feature cohesive with nearby patterns." +policy: + allow_implicit_invocation: true diff --git a/skills/deploy-safety/SKILL.md b/skills/deploy-safety/SKILL.md new file mode 100644 index 0000000..7e65d30 --- /dev/null +++ b/skills/deploy-safety/SKILL.md @@ -0,0 +1,12 @@ +--- +name: deploy-safety +description: "Plan compatible rollout, migration, startup, infrastructure, and live-service boundaries before deployment-sensitive work." +--- + +# Deploy Safety + +1. Read [repository context](references/repository-context.md) and [infrastructure contracts](references/infrastructure.md), then the target project's actual deployment contract. +2. Identify old/new runtime compatibility, destructive schema or serialized-state changes, startup/migration owners, artifact provenance, rollout ordering, pause/force controls, routes, replicas, and service dependencies. +3. Choose one compatible release, staged expand/contract releases, or source change plus explicit operator follow-up. Do not combine destructive removal with runtime removal when old processes may still observe the schema. +4. State the exact implementation/rollout boundary, native preflight, postdeploy evidence, manual actions, and recovery limitations. Do not assume provider reconciliation covers every manifest field. +5. This skill plans rather than applies the rollout. Continue independent preparation; pause only a dependent operation missing a concrete compatibility/product choice or authorization. diff --git a/skills/deploy-safety/agents/openai.yaml b/skills/deploy-safety/agents/openai.yaml new file mode 100644 index 0000000..91aad43 --- /dev/null +++ b/skills/deploy-safety/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Deploy Safety" + short_description: "Plan deploy-safe rollout and migration boundaries" + default_prompt: "Use $deploy-safety to define a rollout-safe boundary for this change." +policy: + allow_implicit_invocation: true diff --git a/skills/docs-audit/SKILL.md b/skills/docs-audit/SKILL.md new file mode 100644 index 0000000..64881d0 --- /dev/null +++ b/skills/docs-audit/SKILL.md @@ -0,0 +1,12 @@ +--- +name: docs-audit +description: "Audit repository documentation read-only for factual drift, duplicated guidance, stale commands, and missing ownership." +--- + +# Docs Audit + +1. Read [repository context](references/repository-context.md), discover canonical routers/inventories and requested docs, and trace claims to owning config, code, Make targets, tests, and capability definitions. +2. Check path/link validity, command execution location, prerequisites/side effects, local versus remote behavior, removed automation, and overstated validation or live-health guarantees. +3. Prefer one owning explanation with concise pointers over duplicated policy. Distinguish durable docs from issue-owned backlog and transient notes according to project policy. +4. Return stable `DOC-NNN` IDs with target-doc and supporting-source lines, impact/confidence, and proposed owning correction. Say when no actionable drift exists and state reviewed scope/limits. +5. Stay read-only; do not edit files, run rewriting hooks, publish findings, probe live APIs merely to validate prose, or recreate missing automation. Assign requested repairs to a separate maintenance action. diff --git a/skills/docs-audit/agents/openai.yaml b/skills/docs-audit/agents/openai.yaml new file mode 100644 index 0000000..8118703 --- /dev/null +++ b/skills/docs-audit/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Docs Audit" + short_description: "Read-only audit for internal docs drift and cohesion" + default_prompt: "Use $docs-audit to find drift and duplication in the repository documentation." +policy: + allow_implicit_invocation: true diff --git a/skills/docs-maintain/SKILL.md b/skills/docs-maintain/SKILL.md new file mode 100644 index 0000000..d91fd9e --- /dev/null +++ b/skills/docs-maintain/SKILL.md @@ -0,0 +1,12 @@ +--- +name: docs-maintain +description: "Create, correct, consolidate, and refactor repository documentation using verified source behavior and existing ownership." +--- + +# Docs Maintain + +1. Read [repository context](references/repository-context.md), resolve the requested docs boundary, and use an existing audit or targeted source comparison to identify concrete drift. +2. Update the canonical owner in place. Keep top-level routers concise; preserve distinct audience needs while removing contradictory duplicate guidance. +3. Trace operational instructions to their actual implementation, prerequisites, execution location, artifact source, and side effects. Describe absent automation as absent; do not change implementation to make stale prose true. +4. Keep product-specific depth with its owner and mutable inventory data in the canonical inventory. Preserve project policy for backlog, private operator material, public docs, and generated files. +5. Run appropriate docs/metadata validation and report changes with source evidence and remaining unknowns. Publishing or deploying requires its own existing authorization. diff --git a/skills/docs-maintain/agents/openai.yaml b/skills/docs-maintain/agents/openai.yaml new file mode 100644 index 0000000..f5beb4d --- /dev/null +++ b/skills/docs-maintain/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Docs Maintain" + short_description: "Create and refactor internal repo docs from repo truth" + default_prompt: "Use $docs-maintain to update and consolidate the repository documentation." +policy: + allow_implicit_invocation: true diff --git a/skills/dokploy-ops/SKILL.md b/skills/dokploy-ops/SKILL.md new file mode 100644 index 0000000..c2c6157 --- /dev/null +++ b/skills/dokploy-ops/SKILL.md @@ -0,0 +1,14 @@ +--- +name: dokploy-ops +description: "Inspect Dokploy-managed application, routing, scaling, and managed-service state before narrowly scoped authorized recovery." +--- + +# Dokploy Ops + +Read [repository context](references/repository-context.md) and [read-first operations](references/live-ops.md). + +1. Discover available Dokploy read tools and inspect project/application state, monitoring, routing/certificates, and affected managed database/cache configuration. +2. Compare actual image/rollout, replicas/placement, domains, service arguments, protocol health, and fresh error evidence with the repository's owning deployment contract. +3. Separate confirmed drift, suspected drift, provider recovery, and source changes. Use exact timestamps/SHAs/service references without credentials or private payloads. +4. Only apply a recovery covered by current authorization, using the smallest redeploy, reload, or targeted configuration update. Inspect helper side effects and do not infer automatic reconciliation. +5. Stop on recovery failure and report partial state before another mutation strategy. Do not perform teardown unless explicit. Route compatibility planning and operator-doc repair to their owning workflow. diff --git a/skills/dokploy-ops/agents/openai.yaml b/skills/dokploy-ops/agents/openai.yaml new file mode 100644 index 0000000..75f663b --- /dev/null +++ b/skills/dokploy-ops/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Dokploy Ops" + short_description: "Inspect live Dokploy state and guide safe operator recovery" + default_prompt: "Use $dokploy-ops to inspect Dokploy state and plan safe operator recovery." +policy: + allow_implicit_invocation: false diff --git a/skills/maintenance-workflow/SKILL.md b/skills/maintenance-workflow/SKILL.md new file mode 100644 index 0000000..f001fef --- /dev/null +++ b/skills/maintenance-workflow/SKILL.md @@ -0,0 +1,14 @@ +--- +name: maintenance-workflow +description: "Select and run an explicitly requested repository maintenance playbook, or plan a separately authorized scheduler, without enabling recurring work implicitly." +--- + +# Maintenance Workflow + +Read [repository context](references/repository-context.md), [workflow index](references/workflow-index.md), and [shared maintenance rules](references/automation-guidelines.md), then only the selected lane. + +Resolve repository, author scope, validation commands, labels, discussion memory, timezone, integration coverage, and readiness rules from its repository guidance. Generic playbooks do not import another project's policy or authorize outbound writes. Missing required context blocks only dependent work. + +Run one bounded work item per delegated worker when the selected playbook uses a dispatcher. Preserve lane markers, head/snapshot deduplication, source evidence, explicit write authority, lock ownership, and observed validation. Keep legacy deduplication aliases in the project guidance. + +The [orchestrator](references/workflows/orchestrator.md) is an optional manual dispatcher contract. Installing these resources creates no scheduler, background job, memory discussion, label, issue, or PR. Configure recurring work only when requested through the host's supported automation tools. Preserve notification intent and stay quiet on unchanged non-actionable state for heartbeat monitors. diff --git a/skills/maintenance-workflow/agents/openai.yaml b/skills/maintenance-workflow/agents/openai.yaml new file mode 100644 index 0000000..866a3bc --- /dev/null +++ b/skills/maintenance-workflow/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Maintenance Workflow" + short_description: "Run a selected repository maintenance playbook" + default_prompt: "Use $maintenance-workflow to select and run the requested maintenance playbook." diff --git a/skills/observability-diagnosis/SKILL.md b/skills/observability-diagnosis/SKILL.md new file mode 100644 index 0000000..adae0ab --- /dev/null +++ b/skills/observability-diagnosis/SKILL.md @@ -0,0 +1,14 @@ +--- +name: observability-diagnosis +description: "Diagnose observed service health using bounded metrics, logs, application checks, and host evidence without changing monitoring or services." +--- + +# Observability Diagnosis + +Read [repository context](references/repository-context.md) and [bounded observability](references/observability.md). + +1. Resolve symptom, service/host, observation interval, and expected telemetry from owning inventories/configuration. +2. Discover available read-only provider/Grafana tools and datasource labels; otherwise use established non-mutating API/SSH checks after inspecting prerequisites. Do not install/reconfigure access to manufacture evidence. +3. Check freshness/coverage before interpreting results. Bound queries and distinguish application health, scrape health, ingestion, resource pressure, and externally managed routing. +4. Test the failing protocol directly and correlate only configured signals. Empty/stale evidence is a gap, not proof of health. +5. Return timestamps/timezone, source/query identity, impact, supported diagnosis/confidence, alternatives, gaps, and next discriminating check. Stay read-only, including dashboards, alerts, issues, credentials, and services. Use local runtime investigation for a narrow command/config failure. diff --git a/skills/observability-diagnosis/agents/openai.yaml b/skills/observability-diagnosis/agents/openai.yaml new file mode 100644 index 0000000..d5d6b7e --- /dev/null +++ b/skills/observability-diagnosis/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Observability Diagnosis" + short_description: "Diagnose incidents with bounded live telemetry" + default_prompt: "Use $observability-diagnosis to investigate this incident using live telemetry." + +policy: + allow_implicit_invocation: true diff --git a/skills/pr-comment/SKILL.md b/skills/pr-comment/SKILL.md new file mode 100644 index 0000000..a158000 --- /dev/null +++ b/skills/pr-comment/SKILL.md @@ -0,0 +1,14 @@ +--- +name: pr-comment +description: "Publish selected findings from a prior review report to the matching GitHub PR head, after preparing exact outbound comments." +--- + +# Pr Comment + +Read [PR workflow foundations](references/pr-workflows.md). + +1. Require a prior review report with unambiguous finding IDs and reviewed SHA. Resolve the PR from that report and confirm its current head still matches; otherwise refresh review before publishing. +2. Skip findings already posted using stable review markers such as ``. Never manufacture new findings in this posting workflow. +3. Prepare exact comments. Use inline locations only when the line remains valid in the current diff; otherwise use a general comment. Use a suggestion block only for an exact valid replacement span. +4. Show the concrete outbound comments before posting. The source workflow requires explicit confirmation for publication; reuse explicit approval already supplied for those exact comments, otherwise wait for it before the write. +5. Recheck head and deduplication immediately before publishing. Do not edit code, alter review scope, or post duplicates. Report published links and any skipped/stale finding. diff --git a/skills/pr-comment/agents/openai.yaml b/skills/pr-comment/agents/openai.yaml new file mode 100644 index 0000000..35c644a --- /dev/null +++ b/skills/pr-comment/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Comment" + short_description: "Post selected review findings back to GitHub" + default_prompt: "Use $pr-comment to preview and post selected findings from the current review." +policy: + allow_implicit_invocation: false diff --git a/skills/pr-conflicts/SKILL.md b/skills/pr-conflicts/SKILL.md new file mode 100644 index 0000000..0945522 --- /dev/null +++ b/skills/pr-conflicts/SKILL.md @@ -0,0 +1,15 @@ +--- +name: pr-conflicts +description: "Repair an explicitly requested same-repository PR merge conflict with one inspected base-into-head merge, validation, and authorized publication." +--- + +# Pr Conflicts + +Read [PR workflow foundations](references/pr-workflows.md). + +1. Read live head/base refs and SHAs, changed files, mergeability, and push target. Verify this is a same-repository PR; do not assume fork publication authority. +2. Fetch authorized refs and inspect the real conflict set with `git merge-tree` or equivalent non-mutating inspection. Resolve missing product/deployment judgments before dependent mutation. +3. Follow the user's and repository's checkout/worktree strategy. Verify branch ownership and dirty state before repair, preserve unrelated work, and use isolation only when selected or needed for safety. Perform exactly one base-into-head conflict-repair merge within the requested scope; never rebase or force-push. +4. Preserve newer base fixes while porting the PR intent onto current implementation. Update directly coupled callers/tests; avoid ordinary CI, review-thread cleanup, or unrelated refactoring. +5. Run appropriate compatibility preparation and smallest required validation. If confidence or checks fail, leave the concrete repair for review and do not push. +6. When publication is authorized, create the intentional merge-resolution commit, push normally to the existing head, verify remote head and GitHub mergeability, and hand remaining ordinary PR cleanup to its own task. diff --git a/skills/pr-conflicts/agents/openai.yaml b/skills/pr-conflicts/agents/openai.yaml new file mode 100644 index 0000000..748d5cc --- /dev/null +++ b/skills/pr-conflicts/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Conflicts" + short_description: "Repair PR merge conflicts safely" + default_prompt: "Use $pr-conflicts to repair and validate the current PR's merge conflicts." +policy: + allow_implicit_invocation: false diff --git a/skills/pr-handoff/SKILL.md b/skills/pr-handoff/SKILL.md new file mode 100644 index 0000000..0975b13 --- /dev/null +++ b/skills/pr-handoff/SKILL.md @@ -0,0 +1,14 @@ +--- +name: pr-handoff +description: "Draft a concise PR title, final-change description, and factual operator handoff without publishing or changing Git state." +--- + +# Pr Handoff + +Read [repository context](references/repository-context.md) and [PR metadata conventions](references/pr-metadata.md). + +1. Gather final branch/base/head, actual diff, user/operator impact, clear issue links, observed checks, and published preview links. Use completed gate evidence only when it remains valid. +2. Produce a short title and description following the target project's template. Lead with concrete problem/result, then meaningful grouped changes and material limitations. +3. Provide exact validation outcomes in the operator handoff, distinguishing static checks, targeted tests, coverage, CI, deployment, and live verification. Place them in the PR body only if project policy permits. +4. Do not invent validation, repeat file churn, include private memory citations, or narrate abandoned approaches. Exclude local preview paths and unfinished placeholders. +5. Drafting does not authorize commit, push, PR creation/edit, comments, or deployment. Run a publication gate only when a substantive publication workflow is actually authorized. diff --git a/skills/pr-handoff/agents/openai.yaml b/skills/pr-handoff/agents/openai.yaml new file mode 100644 index 0000000..69d626c --- /dev/null +++ b/skills/pr-handoff/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Handoff" + short_description: "Draft concise PR source material" + default_prompt: "Use $pr-handoff to draft concise PR-ready source material from the final branch head." +policy: + allow_implicit_invocation: true diff --git a/skills/pr-metadata/SKILL.md b/skills/pr-metadata/SKILL.md new file mode 100644 index 0000000..c7ac29b --- /dev/null +++ b/skills/pr-metadata/SKILL.md @@ -0,0 +1,14 @@ +--- +name: pr-metadata +description: "Prepare and normalize GitHub PR titles, descriptions, issue/preview links, and existing labels using the target repository's conventions." +--- + +# Pr Metadata + +Read [PR workflow foundations](references/pr-workflows.md) and [metadata conventions](references/pr-metadata.md). + +1. Gather current branch/base/head, PR state, final diff/impact, project PR template, observed limitations, issue relationships, published previews, and existing labels. +2. Prepare exact title/body/labels before any write. Follow project formatting and path-label mappings; otherwise use a short outcome-focused description and only clearly supported labels. +3. Preserve intentional user-authored context and valid issue qualifiers. Use closing keywords only for completed work. Add preview links only after native attachments or the stable marker comment exist. +4. Keep labels, placeholders, raw output, private memory citations, and unsupported success claims out of the body. Respect project rules separating validation from PR prose. +5. Perform only the already-authorized metadata write. Metadata-only edits do not invoke a mutating branch publication gate or grant auto-merge permission. diff --git a/skills/pr-metadata/agents/openai.yaml b/skills/pr-metadata/agents/openai.yaml new file mode 100644 index 0000000..d2e0959 --- /dev/null +++ b/skills/pr-metadata/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Metadata" + short_description: "Normalize concise PR body and labels" + default_prompt: "Use $pr-metadata to normalize the pull request title, body, and labels." +policy: + allow_implicit_invocation: true diff --git a/skills/pr-preview/SKILL.md b/skills/pr-preview/SKILL.md new file mode 100644 index 0000000..d9efad3 --- /dev/null +++ b/skills/pr-preview/SKILL.md @@ -0,0 +1,14 @@ +--- +name: pr-preview +description: "Capture relevant before/after frontend screenshots through a repository adapter and publish native GitHub attachments with provenance and one owned preview comment." +--- + +# Pr Preview + +Read [PR workflow foundations](references/pr-workflows.md) and the [preview contract](references/preview.md). + +1. Confirm a visible UI change or explicit preview request. Discover the target repository's route registry and capture adapter; do not assume a particular app or test harness. The shared helpers manage files, provenance, and publication; they do not capture screenshots. If a capture adapter or browser access is unavailable, finish feasible preparation and report capture unavailable without inventing PNGs. +2. Select relevant desktop/mobile before/after states when feasible. Use `agentics tools --repo-root PATH preview` to resolve branch paths and take a source snapshot before rendering; record provenance after both targeted/full-page PNGs exist. +3. Keep PNGs and metadata ignored and untracked. Verify actual artifacts before claiming capture. Follow repository real-stack/test/environment policy. +4. Publish only within existing authorization using `agentics tools --repo-root PATH preview-sync`. Enforce matching PR head, account ownership, native-upload prerequisites, unchanged-content cache, duplicate prevention, and capture provenance. +5. Fetch published URLs for a separate authorized metadata update. `preview clean` removes only local artifacts; `preview-sync --delete` requires an explicit comment-deletion request. Missing captures never imply deletion. diff --git a/skills/pr-preview/agents/openai.yaml b/skills/pr-preview/agents/openai.yaml new file mode 100644 index 0000000..47e4c12 --- /dev/null +++ b/skills/pr-preview/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Preview" + short_description: "Capture and upload native GitHub PR previews" + default_prompt: "Use $pr-preview to capture before/after screenshots for this UI change and publish native GitHub attachments to its preview comment." +policy: + allow_implicit_invocation: false diff --git a/skills/pr-refine/SKILL.md b/skills/pr-refine/SKILL.md new file mode 100644 index 0000000..7ae36a7 --- /dev/null +++ b/skills/pr-refine/SKILL.md @@ -0,0 +1,15 @@ +--- +name: pr-refine +description: "Apply focused validated fixes to the current branch or a selected PR from actionable review feedback, local failures, or existing CI failures." +--- + +# Pr Refine + +Read [PR workflow foundations](references/pr-workflows.md). + +1. Resolve branch/PR/head/base and preserve unrelated changes. Stop before editing a protected/default branch unless the user explicitly selected that workflow. +2. Gather actionable review threads, local validation failures, and exact CI evidence. Separate questions, already-fixed items, environment noise, and product decisions from defects. +3. State triage before large edits. Clarify only scope choices not already established; continue independent preparation. +4. Fix confirmed in-scope defects, update meaningful regression tests and owning docs, and validate affected surfaces. Avoid broad cleanup or speculative rewrites. +5. Commit/push/PR updates only within existing authorization. Normalize target-repo metadata and never publish unvalidated changes. Reply/resolve addressed review threads only when that write is included. +6. Take one check snapshot for handoff; monitor CI only when requested or actively diagnosing a failed check. Return fixed items, remaining failures, exact evidence, and residual decisions. diff --git a/skills/pr-refine/agents/openai.yaml b/skills/pr-refine/agents/openai.yaml new file mode 100644 index 0000000..8e0a111 --- /dev/null +++ b/skills/pr-refine/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Refine" + short_description: "Fix PR feedback and validation failures" + default_prompt: "Use $pr-refine to apply targeted fixes for review and validation failures." +policy: + allow_implicit_invocation: false diff --git a/skills/pr-review/SKILL.md b/skills/pr-review/SKILL.md new file mode 100644 index 0000000..729e548 --- /dev/null +++ b/skills/pr-review/SKILL.md @@ -0,0 +1,14 @@ +--- +name: pr-review +description: "Perform an explicitly requested read-only review of a pull request, branch, or local diff for correctness, regressions, security, operational risk, and concrete test gaps." +--- + +# Pr Review + +Read [PR workflow foundations](references/pr-workflows.md). + +1. Resolve requested PR/diff and verified base; otherwise prefer local uncommitted changes or the branch comparison. Inspect relevant owning guidance, changed paths, callers, checks, and existing feedback. +2. Establish concrete triggers and consequences with the smallest non-mutating checks. Inspect helper prerequisites; do not run fixing hooks or infer remote health from local process state. +3. Prioritize correctness, security, regression, deployment failure, and meaningful missing tests. Respect project policy for intentional private configuration; review exposure rather than tracked status alone. +4. Return findings first with stable `REVIEW-NNN` IDs, severity/confidence, exact current-head file/lines, trigger/impact, and smallest repair direction. Include repo/PR/branch/head context, observed validation, duplicates, and residual gaps. Say explicitly when there are no findings. +5. No file edits or external mutations, including comments, reviews, Git, services, dashboards, or credentials. Use a separate authorized refine/comment action for follow-up; structural/docs/design-only issues belong to a focused audit. diff --git a/skills/pr-review/agents/openai.yaml b/skills/pr-review/agents/openai.yaml new file mode 100644 index 0000000..d24e410 --- /dev/null +++ b/skills/pr-review/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "PR Review" + short_description: "Read-only review of the current branch or PR" + default_prompt: "Use $pr-review to review the current branch or pull request without making changes." +policy: + allow_implicit_invocation: false diff --git a/skills/pre-pr-gate/SKILL.md b/skills/pre-pr-gate/SKILL.md new file mode 100644 index 0000000..41ce0d4 --- /dev/null +++ b/skills/pre-pr-gate/SKILL.md @@ -0,0 +1,15 @@ +--- +name: pre-pr-gate +description: "Run a focused final review, repair, and validation gate before authorized substantive branch publication or PR creation." +--- + +# Pre Pr Gate + +Read [PR workflow foundations](references/pr-workflows.md). + +1. Resolve authorized publication scope, branch/base/head, remote target, full diff, dirty state, and preview evidence. Stop for ambiguous unrelated changes, unsafe target, or missing base. Skip metadata-only edits, draft-only handoffs, trivial typo work, and explicit gate bypasses. +2. Reuse gate proof only under the shared proof contract. Otherwise run correctness review, adding exploration, structural/docs audit, test-gap, design/visual, CI, ops, or external-doc specialists only when the actual risk warrants them. Parent equivalent passes are valid when delegation is unavailable. +3. Triage concrete findings as fixable defect, already handled/not real, human judgment, or residual risk. Fix only confirmed scoped defects while preserving user work and required safeguards. +4. Add meaningful tests/docs and relevant previews, then run smallest required checks. Re-review only affected passes. Stop when clean, validation/access blocks, a product decision remains, or the same finding persists after a focused fix attempt. +5. Publish only validated final content under existing authorization. Do not force-push, rebase, or merge the base as part of this gate. Verify remote ref equals validated local HEAD after a normal push. +6. Record `pre-pr-gate: `, base ref/SHA, checked-input/result snapshot, and verified remote identity. Confirm no relevant dirty/untracked content remains, draft handoff/metadata, and state material residual risks. Do not poll pending CI without a monitoring request. diff --git a/skills/pre-pr-gate/agents/openai.yaml b/skills/pre-pr-gate/agents/openai.yaml new file mode 100644 index 0000000..bb294ce --- /dev/null +++ b/skills/pre-pr-gate/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Pre-PR Gate" + short_description: "Final adaptive review and fix gate before PR publication" + default_prompt: "Use $pre-pr-gate to run the final review and validation gate before publication." +policy: + allow_implicit_invocation: true diff --git a/skills/runtime-probe/SKILL.md b/skills/runtime-probe/SKILL.md new file mode 100644 index 0000000..9de9b2b --- /dev/null +++ b/skills/runtime-probe/SKILL.md @@ -0,0 +1,12 @@ +--- +name: runtime-probe +description: "Reproduce an uncertain runtime, command, configuration, validator, CI, or browser failure and trace evidence before choosing a fix." +--- + +# Runtime Probe + +1. Read [repository context](references/repository-context.md). Resolve the failing command/input/route, working directory, runtime, host, artifact source, and expected outcome. +2. Choose the smallest non-mutating or isolated repro through approved validators/tests, exact CI logs, browser evidence, or bounded service reads. Inspect command prerequisites before calling status/log helpers. +3. Do not replay deploy, push, bootstrap, reload, or maintenance commands just to reproduce a symptom. Use an isolated fixture or inspect their implementation unless that operation is already authorized. +4. Trace owning entrypoints, callers, shared helpers, config, and relevant data shapes. Separate code/config defects, command environment failure, connectivity, and live-service failure. +5. Return observed result, supported cause or hypothesis, evidence, confidence, and a falsifying check. Keep secrets redacted. Use a focused change plan once the cause supports a repair; use bounded observability when the question needs broader telemetry correlation. diff --git a/skills/runtime-probe/agents/openai.yaml b/skills/runtime-probe/agents/openai.yaml new file mode 100644 index 0000000..6d6c803 --- /dev/null +++ b/skills/runtime-probe/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Runtime Probe" + short_description: "Reproduce and trace the runtime failure before fixing it" + default_prompt: "Use $runtime-probe to reproduce and trace this runtime failure before fixing it." +policy: + allow_implicit_invocation: true diff --git a/skills/service-integration/SKILL.md b/skills/service-integration/SKILL.md new file mode 100644 index 0000000..2e1d00a --- /dev/null +++ b/skills/service-integration/SKILL.md @@ -0,0 +1,12 @@ +--- +name: service-integration +description: "Align service and host identity, endpoints, inventories, DNS, human-facing links, and applicable telemetry during addition, relocation, or retirement." +--- + +# Service Integration + +1. Read [repository context](references/repository-context.md) and [infrastructure contracts](references/infrastructure.md). Resolve service, management host, and physical-host identities separately from canonical sources. +2. Trace runtime configuration, host defaults/profiles, DNS, endpoint catalogs, metrics discovery, log shipping, uptime/status references, and owning docs. Include only integrations actually supported by the service. +3. Apply the requested local configuration consistently. Reuse existing host identities; do not invent a VM, UI, metrics endpoint, or public-route ownership. On retirement preserve shared hosts and consumers still used elsewhere. +4. Inspect the full desired-state reconciliation diff, including removals, before any later rollout. Run the relevant native validators; a passing inventory validator does not prove every DNS or telemetry consumer is covered. +5. Report identities/endpoints, consumers changed, observed checks, and deployment dependencies. Local integration does not authorize provisioning, deployment, or external routing changes. diff --git a/skills/service-integration/agents/openai.yaml b/skills/service-integration/agents/openai.yaml new file mode 100644 index 0000000..8a80028 --- /dev/null +++ b/skills/service-integration/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Service Integration" + short_description: "Align service identity and cross-stack endpoints" + default_prompt: "Use $service-integration to integrate this service into the repository." + +policy: + allow_implicit_invocation: true diff --git a/skills/stack-rollout/SKILL.md b/skills/stack-rollout/SKILL.md new file mode 100644 index 0000000..0381bc5 --- /dev/null +++ b/skills/stack-rollout/SKILL.md @@ -0,0 +1,13 @@ +--- +name: stack-rollout +description: "Apply an already-authorized deployment or bootstrap through the owning workflow, exact host set, and artifact source, then verify the actual target." +--- + +# Stack Rollout + +1. Read [repository context](references/repository-context.md), [infrastructure contracts](references/infrastructure.md), and the target stack's deployment instructions. +2. Bind exact operation, hosts, runtime environment, artifact ref/source, and execution location. Resolve missing target/artifact decisions before dependent mutation; finish independent preparation meanwhile. +3. Prepare the concrete change and run native preflight. Explain unpublished local changes that a fetched-ref deployment cannot deliver; never silently substitute another ref or publish it. +4. Execute only the authorized operation through its native workflow. Deployment does not imply publication, provisioning, broad maintenance, reboots, rollback, force recreation, or fleet expansion. Reuse authorization already supplied. +5. Preserve native retries; stop on execution failure, collect read-only evidence, and report partial application before choosing a different mutation. Verify artifact identity and service-specific behavior on the actual target with bounded logs/protocol checks. +6. Report intended/observed hosts/artifacts, commands, outcomes, live checks, and remaining gaps. Local Compose or static validation is insufficient evidence for a remote rollout. diff --git a/skills/stack-rollout/agents/openai.yaml b/skills/stack-rollout/agents/openai.yaml new file mode 100644 index 0000000..1f85f91 --- /dev/null +++ b/skills/stack-rollout/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Stack Rollout" + short_description: "Deploy authorized changes and verify the real host" + default_prompt: "Use $stack-rollout to deploy the requested stack and verify its state." + +policy: + allow_implicit_invocation: true diff --git a/src/agentics_tools/__init__.py b/src/agentics_tools/__init__.py new file mode 100644 index 0000000..61bb346 --- /dev/null +++ b/src/agentics_tools/__init__.py @@ -0,0 +1,3 @@ +"""Agentics' portable tooling and managed installation.""" + +__version__ = "0.1.0" diff --git a/src/agentics_tools/__main__.py b/src/agentics_tools/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/agentics_tools/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/agentics_tools/catalog.py b/src/agentics_tools/catalog.py new file mode 100644 index 0000000..691c3d5 --- /dev/null +++ b/src/agentics_tools/catalog.py @@ -0,0 +1,157 @@ +"""Load the canonical catalog and assemble self-contained installation bundles.""" + +from __future__ import annotations + +import json +import re +import stat +import tomllib +from pathlib import Path, PurePosixPath + +GROUPS = {"core", "github", "docs", "ops", "maintenance"} + + +def relative_path(value: str) -> PurePosixPath: + if not isinstance(value, str) or not value or "\\" in value: + raise ValueError(f"Unsafe relative path: {value!r}") + path = PurePosixPath(value) + if path.is_absolute() or any(part in {"", ".", ".."} for part in value.split("/")): + raise ValueError(f"Unsafe relative path: {value!r}") + return path + + +def checked_path(root: Path, relative: str) -> Path: + path = root + for part in relative_path(relative).parts: + path = path / part + if path.is_symlink(): + raise ValueError(f"Symlink in catalog: {path}") + if not path.exists(): + raise ValueError(f"Missing catalog resource: {path}") + return path + + +class Catalog: + def __init__(self, root: Path): + self.root = Path(root).resolve() + raw = json.loads(checked_path(self.root, "catalog.json").read_text()) + if raw.get("schema_version") != 1 or not isinstance(raw.get("version"), str): + raise ValueError("Unsupported catalog schema or missing version") + self.version = raw["version"] + self.items: dict[str, dict] = {} + seen_names: set[tuple[str, str]] = set() + destinations: set[tuple[str, str]] = set() + for item in raw.get("items", []): + kind, name = item.get("kind"), item.get("name", "") + if kind not in {"skill", "agent"} or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", name): + raise ValueError(f"Invalid catalog item: {item!r}") + if kind == "skill" and "_" in name: + raise ValueError(f"Invalid skill name: {name}") + identifier = item.get("id") + if identifier != f"{kind}:{name}" or identifier in self.items or (kind, name) in seen_names: + raise ValueError(f"Duplicate or invalid catalog ID: {identifier}") + if item.get("group") not in GROUPS: + raise ValueError(f"Invalid group for {identifier}") + source = checked_path(self.root, item["path"]) + if (kind == "skill" and not source.is_dir()) or (kind == "agent" and not source.is_file()): + raise ValueError(f"Invalid source type for {identifier}") + destination = (kind, name if kind == "skill" else source.name) + if destination in destinations: + raise ValueError(f"Duplicate installation destination: {destination}") + destinations.add(destination) + if not isinstance(item.get("dependencies", []), list) or not isinstance(item.get("resources", {}), dict): + raise ValueError(f"Invalid dependencies/resources for {identifier}") + if kind == "agent" and (source.suffix != ".toml" or item.get("resources")): + raise ValueError(f"Agents must be single TOML files: {identifier}") + self.items[identifier] = item + seen_names.add((kind, name)) + if not self.items: + raise ValueError("Empty catalog") + for identifier, item in self.items.items(): + for dep in item.get("dependencies", []): + if dep not in self.items: + raise ValueError(f"Unknown dependency {dep!r} for {identifier}") + self.bundle(identifier) + + def select(self, *, skills=(), agents=(), groups=(), all_items=False) -> list[str]: + unknown = set(groups) - GROUPS + if unknown: + raise ValueError(f"Unknown groups: {', '.join(sorted(unknown))}") + selected = set(self.items) if all_items else set() + selected.update(f"skill:{name}" for name in skills) + selected.update(f"agent:{name}" for name in agents) + selected.update(key for key, item in self.items.items() if item["group"] in groups) + if selected - self.items.keys(): + raise ValueError(f"Unknown items: {', '.join(sorted(selected - self.items.keys()))}") + visited: set[str] = set() + + def visit(key: str, trail: set[str]): + if key in trail: + raise ValueError(f"Cyclic catalog dependency: {key}") + if key in visited: + return + for dep in self.items[key].get("dependencies", []): + visit(dep, trail | {key}) + visited.add(key) + + for key in selected: + visit(key, set()) + return sorted(visited) + + def bundle(self, identifier: str) -> dict[str, tuple[bytes, int]]: + item = self.items[identifier] + source = checked_path(self.root, item["path"]) + files: dict[str, tuple[bytes, int]] = {} + + def add(path: Path, target: str): + relative_path(target) + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Expected regular catalog file: {path}") + mode = stat.S_IMODE(path.stat().st_mode) & 0o777 + contents = (path.read_bytes(), mode) + if target in files and files[target] != contents: + raise ValueError(f"Conflicting bundled resource: {identifier}/{target}") + files[target] = contents + + if item["kind"] == "skill": + for path in sorted(source.rglob("*")): + if path.is_symlink(): + raise ValueError(f"Symlink in catalog: {path}") + if path.is_file(): + add(path, path.relative_to(source).as_posix()) + else: + add(source, source.name) + for target, resource in item.get("resources", {}).items(): + resource_path = checked_path(self.root, resource) + if resource_path.is_dir(): + for path in sorted(resource_path.rglob("*")): + if path.is_symlink(): + raise ValueError(f"Symlink in resource: {path}") + if path.is_file(): + add(path, f"{target}/{path.relative_to(resource_path).as_posix()}") + else: + add(resource_path, target) + for name in files: + if any(parent.as_posix() in files for parent in Path(name).parents if parent != Path(".")): + raise ValueError(f"File/directory collision in bundle: {identifier}/{name}") + if item["kind"] == "agent": + data = tomllib.loads(files[source.name][0].decode()) + if data.get("name") != item["name"] or not all(isinstance(data.get(k), str) and data[k].strip() for k in ("description", "developer_instructions")): + raise ValueError(f"Invalid agent definition: {identifier}") + else: + skill = files.get("SKILL.md", (b"", 0))[0].decode() + front = re.match(r"\A---\s*\n(.*?)\n---(?:\n|$)", skill, re.S) + if not front: + raise ValueError(f"Missing skill frontmatter: {identifier}") + header = front.group(1) + name_match = re.search(r"^name:\s*['\"]?([^\n'\"]+)['\"]?\s*$", header, re.M) + if not name_match or name_match.group(1).strip() != item["name"] or not re.search(r"^description:\s*\S", header, re.M): + raise ValueError(f"Invalid skill name/description: {identifier}") + return files + + +def default_catalog() -> Catalog: + packaged = Path(__file__).parent / "data" + if packaged.is_dir(): + return Catalog(packaged) + return Catalog(Path(__file__).resolve().parents[2]) diff --git a/src/agentics_tools/cli.py b/src/agentics_tools/cli.py new file mode 100644 index 0000000..668548b --- /dev/null +++ b/src/agentics_tools/cli.py @@ -0,0 +1,128 @@ +"""Agentics catalog, installation, and helper command line.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +from . import __version__ +from .catalog import Catalog, default_catalog +from . import installer + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(prog="agentics", description=__doc__) + result.add_argument("--version", action="version", version=__version__) + result.add_argument("--catalog", type=Path, help="Use an explicit catalog directory") + commands = result.add_subparsers(dest="command", required=True) + for command in ("list", "install", "update", "status", "uninstall"): + sub = commands.add_parser(command) + sub.add_argument("--skill", action="append", default=[]) + sub.add_argument("--agent", action="append", default=[]) + sub.add_argument("--group", action="append", default=[]) + sub.add_argument("--all", action="store_true") + sub.add_argument("--json", action="store_true", dest="as_json") + if command != "list": + sub.add_argument("--home", type=Path, default=Path.home(), help="User home (for isolated installation/testing)") + sub.add_argument("--codex-home", type=Path, default=Path(os.environ["CODEX_HOME"]) if os.environ.get("CODEX_HOME") else None) + if command in {"install", "update", "uninstall"}: + sub.add_argument("--dry-run", action="store_true") + commands.add_parser("validate", help="Validate catalog metadata, resources, and local links") + workflows = commands.add_parser("workflows") + workflows.add_argument("action", choices=("list", "show")) + workflows.add_argument("name", nargs="?") + tools = commands.add_parser("tools", help="Portable GitHub and preview tools") + tools.add_argument("--repo-root", type=Path) + tools.add_argument("tool", choices=("pr-threads", "preview", "preview-sync")) + tools.add_argument("arguments", nargs=argparse.REMAINDER) + return result + + +def _tools(args) -> int: + from .tools import context, pr_threads, preview, preview_sync + arguments = list(args.arguments) + # Also accept the target after the namespace, before helper arguments. + for index, value in enumerate(arguments): + if value == "--repo-root": + if args.repo_root is not None or index + 1 == len(arguments): + raise ValueError("Pass --repo-root once with a directory") + args.repo_root = Path(arguments[index + 1]) + del arguments[index:index + 2] + break + if value.startswith("--repo-root="): + if args.repo_root is not None: + raise ValueError("Pass --repo-root once") + args.repo_root = Path(value.split("=", 1)[1]) + del arguments[index] + break + context.set_repo_root(args.repo_root) + module = {"pr-threads": pr_threads, "preview": preview, "preview-sync": preview_sync}[args.tool] + previous = sys.argv + try: + sys.argv = [f"agentics tools {args.tool}", *arguments] + return module.main() or 0 + finally: + sys.argv = previous + context.set_repo_root(None) + + +def main(argv=None) -> int: + command_parser = parser() + args = command_parser.parse_args(argv) + try: + if args.command == "tools": + return _tools(args) + catalog = Catalog(args.catalog) if args.catalog else default_catalog() + if args.command == "validate": + from .validation import validate + validate(catalog) + print(f"Valid catalog {catalog.version}: {len(catalog.items)} items") + return 0 + if args.command == "workflows": + workflows = catalog.root / "workflows" + if args.action == "list": + for path in sorted(workflows.glob("*.md")): + print(path.stem) + else: + if args.name is None or not args.name.replace("-", "").isalnum(): + raise ValueError("Pass a workflow ID from 'agentics workflows list'") + path = workflows / f"{args.name}.md" + if not path.is_file(): + raise ValueError(f"Unknown workflow: {args.name}") + print(path.read_text(), end="") + return 0 + selection = bool(args.skill or args.agent or args.group or args.all) + ids = catalog.select(skills=args.skill, agents=args.agent, groups=args.group, all_items=args.all) + if args.command == "list": + rows = [dict(id=key, kind=item["kind"], group=item["group"], dependencies=item.get("dependencies", [])) for key, item in catalog.items.items() if not selection or key in ids] + else: + layout = installer.Layout(args.home, args.codex_home) + if args.command == "status": + rows = installer.status(catalog, layout, ids if selection else None) + else: + if args.command == "install" and not selection: + command_parser.error("install requires --skill, --agent, --group, or --all") + if args.command in {"update", "uninstall"}: + installed = set(installer.installed_ids(layout)) + if args.all or not selection: + ids = sorted(installed) + elif args.command == "uninstall": + # Removing a skill does not implicitly remove its dependency roles. + ids = sorted({f"skill:{s}" for s in args.skill} | {f"agent:{a}" for a in args.agent} | {key for key, item in catalog.items.items() if item["group"] in args.group}) + rows = getattr(installer, args.command)(catalog, layout, ids, args.dry_run) + if args.as_json: + print(json.dumps(rows, indent=2)) + else: + for row in rows: + print(f"{row['id']}: {row.get('action', row.get('status', row.get('group')))}" + (f" [{row['status']}]" if 'action' in row else "")) + return 0 + except (ValueError, OSError, RuntimeError) as exc: + print(f"agentics: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentics_tools/installer.py b/src/agentics_tools/installer.py new file mode 100644 index 0000000..85e2c6d --- /dev/null +++ b/src/agentics_tools/installer.py @@ -0,0 +1,381 @@ +"""Conservative managed-copy installation; never adopts or overwrites unknown files.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import stat +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from .catalog import Catalog, relative_path + + +@dataclass +class Layout: + home: Path + codex_home: Path | None = None + + def __post_init__(self): + self.home = Path(self.home).expanduser().resolve() + self.codex_home = Path(self.codex_home or self.home / ".codex").expanduser().resolve() + + @property + def skill_root(self) -> Path: + return self.home / ".agents" / "skills" + + @property + def agent_root(self) -> Path: + return self.codex_home / "agents" + + @property + def state_path(self) -> Path: + return self.codex_home / "agentics" / "installed.json" + + +def _check_ancestors(path: Path, root: Path): + if not path.is_relative_to(root): + raise ValueError(f"Destination escapes installation root: {path}") + for parent in reversed((path, *path.parents)): + if parent == root or parent.is_relative_to(root): + if parent.is_symlink(): + raise ValueError(f"Symlink in installation path: {parent}") + if parent != path and parent.exists() and not parent.is_dir(): + raise ValueError(f"Not a directory: {parent}") + + +def _destination(layout: Layout, record: dict) -> Path: + if record["kind"] == "skill": + path = layout.skill_root / record["name"] + _check_ancestors(path, layout.home) + else: + filename = record.get("filename", Path(record.get("path", "")).name) + if not filename or len(relative_path(filename).parts) != 1 or not filename.endswith(".toml"): + raise ValueError("Unsafe agent filename in installation state") + path = layout.agent_root / filename + _check_ancestors(path, layout.codex_home) + return path + + +def _file_info(data: bytes, mode: int) -> dict: + return {"type": "file", "sha256": hashlib.sha256(data).hexdigest(), "mode": mode} + + +def _bundle_snapshot(bundle: dict) -> dict: + result = {} + for relative, (contents, mode) in bundle.items(): + result[relative] = _file_info(contents, mode) + for parent in Path(relative).parents: + if parent != Path("."): + result[parent.as_posix()] = {"type": "directory"} + return result + + +def _bundle(catalog: Catalog, key: str, record=None) -> dict: + bundle = catalog.bundle(key) + if catalog.items[key]["kind"] == "agent" and record: + # Source filename changes do not orphan an already installed role. + bundle = {record["filename"]: next(iter(bundle.values()))} + return bundle + + +def _snapshot(path: Path, kind: str) -> dict: + if path.is_symlink(): + raise ValueError(f"Symlink at managed destination: {path}") + if not path.exists(): + return {} + if kind == "agent": + if not path.is_file(): + raise ValueError(f"Expected managed file: {path}") + return {path.name: _file_info(path.read_bytes(), stat.S_IMODE(path.stat().st_mode))} + if not path.is_dir(): + raise ValueError(f"Expected managed directory: {path}") + result = {} + for child in sorted(path.rglob("*")): + if child.is_symlink(): + raise ValueError(f"Symlink in managed bundle: {child}") + relative = child.relative_to(path).as_posix() + if child.is_dir(): + result[relative] = {"type": "directory"} + elif child.is_file(): + result[relative] = _file_info(child.read_bytes(), stat.S_IMODE(child.stat().st_mode)) + else: + raise ValueError(f"Unsupported file in managed bundle: {child}") + return result + + +def _read_state(layout: Layout) -> dict: + _check_ancestors(layout.state_path, layout.codex_home) + if not layout.state_path.exists(): + return {"schema_version": 1, "items": {}} + state = json.loads(layout.state_path.read_text()) + if state.get("schema_version") != 1 or not isinstance(state.get("items"), dict): + raise ValueError("Invalid Agentics installation state") + for key, record in state["items"].items(): + kind, name = record.get("kind"), record.get("name", "") + if kind not in {"skill", "agent"} or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", name) or key != f"{kind}:{name}": + raise ValueError(f"Invalid managed item: {key}") + _destination(layout, record) + if not isinstance(record.get("files"), dict) or not record["files"]: + raise ValueError(f"Invalid managed snapshot: {key}") + for relative, info in record["files"].items(): + relative_path(relative) + if info.get("type") not in {"file", "directory"}: + raise ValueError(f"Invalid managed file: {key}/{relative}") + return state + + +def installed_ids(layout: Layout) -> list[str]: + """Return owned identities even if their files are modified or collide.""" + return sorted(_read_state(layout)["items"]) + + +def _role_collisions(layout: Layout, item: dict, destination: Path) -> list[str]: + collisions = [] + _check_ancestors(layout.agent_root, layout.codex_home) + if layout.agent_root.exists(): + for role in layout.agent_root.glob("*.toml"): + if role == destination: + continue + if role.is_symlink(): + raise ValueError(f"Symlink in agent directory: {role}") + try: + role_data = tomllib.loads(role.read_text()) + except (OSError, ValueError) as exc: + raise ValueError(f"Cannot inspect agent definition: {role}: {exc}") from exc + if role_data.get("name") == item["name"]: + collisions.append(str(role)) + config = layout.codex_home / "config.toml" + _check_ancestors(config, layout.codex_home) + if config.exists(): + config_data = tomllib.loads(config.read_text()) + if isinstance(config_data.get("agents", {}).get(item["name"]), dict): + collisions.append(f"{config} [agents.{item['name']}]") + for role_name, registration in config_data.get("agents", {}).items(): + if not isinstance(registration, dict) or not registration.get("config_file"): + continue + role_path = Path(registration["config_file"]).expanduser() + if not role_path.is_absolute(): + role_path = config.parent / role_path + if role_path.is_file(): + role_data = tomllib.loads(role_path.read_text()) + if role_data.get("name") == item["name"]: + collisions.append(f"{config} [agents.{role_name}]") + return collisions + + +def _skill_collisions(layout: Layout, item: dict, destination: Path) -> list[str]: + collisions = [] + _check_ancestors(layout.skill_root, layout.home) + if layout.skill_root.exists(): + for folder in layout.skill_root.iterdir(): + if folder == destination: + continue + manifest = folder / "SKILL.md" + if manifest.is_file(): + match = re.search(r"^name:\s*['\"]?([^\n'\"]+)['\"]?\s*$", manifest.read_text(), re.M) + if match and match.group(1).strip() == item["name"]: + collisions.append(str(folder)) + return collisions + + +def status(catalog: Catalog, layout: Layout, ids=None) -> list[dict]: + state = _read_state(layout) + selected = sorted(set(ids if ids is not None else set(catalog.items) | set(state["items"]))) + rows = [] + for key in selected: + item = catalog.items.get(key) + record = state["items"].get(key) + if item is None and record is None: + raise ValueError(f"Unknown item: {key}") + destination = _destination(layout, record or item) + row = {"id": key, "path": str(destination)} + try: + actual = _snapshot(destination, (record or item)["kind"]) + except ValueError: + actual = None + collisions = [] + if item: + checker = _role_collisions if item["kind"] == "agent" else _skill_collisions + collisions = checker(layout, item, destination) + if collisions: + row.update(status="collision", conflicts=collisions) + elif record is None: + row["status"] = "collision" if destination.exists() or destination.is_symlink() else "not-installed" + elif not destination.exists() and not destination.is_symlink(): + row["status"] = "missing" + elif actual != record["files"]: + row["status"] = "modified" + elif item is None: + row["status"] = "source-unavailable" + elif (actual == _bundle_snapshot(_bundle(catalog, key, record)) + and sorted(record.get("dependencies", [])) == sorted(item.get("dependencies", [])) + and record.get("version") == catalog.version): + row["status"] = "current" + else: + row["status"] = "update-available" + rows.append(row) + return rows + + +def _record(catalog: Catalog, key: str, bundle: dict) -> dict: + item = catalog.items[key] + return { + "kind": item["kind"], "name": item["name"], + "filename": next(iter(bundle)) if item["kind"] == "agent" else None, + "version": catalog.version, "files": _bundle_snapshot(bundle), + "dependencies": item.get("dependencies", []), + } + + +def _plan(operation: str, catalog: Catalog, layout: Layout, ids: list[str]): + state = _read_state(layout) + rows = status(catalog, layout, ids) + errors, changes = [], [] + for row in rows: + key, current = row["id"], row["status"] + if current in {"collision", "modified", "missing"}: + errors.append(f"{key}: {current} ({row['path']})") + elif operation == "install": + if current == "not-installed": + changes.append((key, "install")) + elif current != "current": + errors.append(f"{key}: already installed; use update") + elif operation == "update": + if current == "not-installed": + errors.append(f"{key}: not installed; use install") + elif current == "source-unavailable": + errors.append(f"{key}: source unavailable") + elif current == "update-available": + changes.append((key, "update")) + elif operation == "uninstall" and current != "not-installed": + changes.append((key, "uninstall")) + if operation == "uninstall": + removed = {key for key, _ in changes} + for key, record in state["items"].items(): + if key not in removed and removed.intersection(record.get("dependencies", [])): + errors.append(f"{key}: depends on an item selected for uninstall") + else: + available = set(state["items"]) | {key for key, action in changes if action == "install"} + for key in ids: + if key not in catalog.items: + continue + for dependency in catalog.items[key].get("dependencies", []): + if dependency not in available: + errors.append(f"{key}: install required dependency {dependency} first") + if errors: + raise ValueError("Operation refused before writing:\n" + "\n".join(errors)) + return state, rows, changes + + +def _remove(path: Path): + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + + +def _operate(operation: str, catalog: Catalog, layout: Layout, ids, dry_run: bool): + ids = list(ids) + state, rows, changes = _plan(operation, catalog, layout, ids) + for row in rows: + row["action"] = next((action for key, action in changes if key == row["id"]), "unchanged") + row["dry_run"] = dry_run + if dry_run or not changes: + return rows + layout.state_path.parent.mkdir(parents=True, exist_ok=True) + lock = layout.state_path.with_suffix(".lock") + try: + descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError as exc: + raise ValueError(f"Installation lock exists: {lock}; inspect any interrupted operation before retrying") from exc + staged = [] + completed = [] + preserve_recovery = False + try: + os.write(descriptor, str(os.getpid()).encode()) + os.close(descriptor) + state, _, changes = _plan(operation, catalog, layout, ids) + # Stage every bundle before replacing any destination. + for key, action in changes: + item = catalog.items.get(key) or state["items"][key] + destination = _destination(layout, state["items"].get(key) or item) + destination.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".agentics-", dir=destination.parent)) + payload = staging / "payload" + bundle = None if action == "uninstall" else _bundle(catalog, key, state["items"].get(key)) + if bundle: + if item["kind"] == "agent": + contents, mode = next(iter(bundle.values())) + payload.write_bytes(contents) + payload.chmod(mode) + else: + payload.mkdir() + for relative, (contents, mode) in bundle.items(): + output = payload / relative + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(contents) + output.chmod(mode) + staged.append((key, action, destination, staging, payload, bundle)) + for key, action, destination, staging, payload, bundle in staged: + backup = staging / "backup" + existed = destination.exists() + if existed: + destination.rename(backup) + completed.append((destination, backup, existed)) + if action == "uninstall": + del state["items"][key] + else: + payload.rename(destination) + state["items"][key] = _record(catalog, key, bundle) + state["catalog_version"] = catalog.version + state_fd, state_name = tempfile.mkstemp(prefix=".installed-", dir=layout.state_path.parent) + state_temp = Path(state_name) + try: + with os.fdopen(state_fd, "w") as stream: + stream.write(json.dumps(state, indent=2, sort_keys=True) + "\n") + stream.flush() + os.fsync(stream.fileno()) + state_temp.replace(layout.state_path) + finally: + state_temp.unlink(missing_ok=True) + except BaseException as operation_error: + recovery_errors = [] + for destination, backup, existed in reversed(completed): + try: + _remove(destination) + if existed: + backup.rename(destination) + except BaseException as recovery_error: + recovery_errors.append(f"{destination}: {recovery_error}") + if recovery_errors: + preserve_recovery = True + recovery_paths = ", ".join(str(staging) for _, _, _, staging, _, _ in staged) + raise RuntimeError( + f"Operation failed and rollback incomplete. Installation lock retained at {lock}. " + f"Recovery data retained in: {recovery_paths}\n" + "\n".join(recovery_errors) + ) from operation_error + raise + finally: + if not preserve_recovery: + for _, _, _, staging, _, _ in staged: + shutil.rmtree(staging, ignore_errors=True) + lock.unlink(missing_ok=True) + return rows + + +def install(catalog: Catalog, layout: Layout, ids, dry_run=False): + return _operate("install", catalog, layout, ids, dry_run) + + +def update(catalog: Catalog, layout: Layout, ids, dry_run=False): + return _operate("update", catalog, layout, ids, dry_run) + + +def uninstall(catalog: Catalog, layout: Layout, ids, dry_run=False): + return _operate("uninstall", catalog, layout, ids, dry_run) diff --git a/src/agentics_tools/tools/__init__.py b/src/agentics_tools/tools/__init__.py new file mode 100644 index 0000000..f9e65ea --- /dev/null +++ b/src/agentics_tools/tools/__init__.py @@ -0,0 +1 @@ +"""Portable GitHub review and preview tools.""" diff --git a/src/agentics_tools/tools/context.py b/src/agentics_tools/tools/context.py new file mode 100644 index 0000000..a75e2a7 --- /dev/null +++ b/src/agentics_tools/tools/context.py @@ -0,0 +1,46 @@ +"""Resolve tool operations against the caller's selected Git repository.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +_selected_repo_root: Path | None = None + + +def _git_root(directory: Path) -> Path: + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=directory, + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise RuntimeError(f"Unable to inspect Git repository at {directory}: {exc}") from exc + if result.returncode != 0 or not result.stdout.strip(): + raise RuntimeError(f"No Git repository found at {directory}. Use --repo-root to select one.") + return Path(result.stdout.strip()).resolve() + + +def set_repo_root(path: str | Path | None) -> None: + """Select a Git repository, or reset to discovery from the current directory.""" + global _selected_repo_root + if path is None: + _selected_repo_root = None + return + directory = Path(path).expanduser().resolve() + if not directory.is_dir(): + raise ValueError(f"Repository directory does not exist: {directory}") + _selected_repo_root = _git_root(directory) + + +def repo_root() -> Path: + """Return the selected root, otherwise discover the caller's Git repository.""" + return _selected_repo_root if _selected_repo_root is not None else _git_root(Path.cwd()) + + +def has_explicit_repo_root() -> bool: + """Whether the caller selected a repository rather than using ambient context.""" + return _selected_repo_root is not None diff --git a/src/agentics_tools/tools/pr_threads.py b/src/agentics_tools/tools/pr_threads.py new file mode 100644 index 0000000..5186d1b --- /dev/null +++ b/src/agentics_tools/tools/pr_threads.py @@ -0,0 +1,812 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +import subprocess +import sys +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from .context import has_explicit_repo_root, repo_root + +import requests + +GRAPHQL_URL = "https://api.github.com/graphql" +PAGE_SIZE = 100 +DEFAULT_MERGED_BASE_REF = "main" +GITHUB_SEARCH_EARLIEST_DATE = date(2008, 1, 1) +MERGED_PULL_REQUEST_WINDOW_DAYS = 30 + +OPEN_PULL_REQUESTS_QUERY = """ +query($query: String!, $after: String) { + search(type: ISSUE, query: $query, first: {PAGE_SIZE}, after: $after) { + nodes { + ... on PullRequest { + id + number + title + url + isDraft + headRefName + headRefOid + baseRefName + updatedAt + headRepositoryOwner { + login + } + headRepository { + name + } + reviewThreads(first: {PAGE_SIZE}) { + totalCount + nodes { + isResolved + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +""".replace("{PAGE_SIZE}", str(PAGE_SIZE)) + +MERGED_PULL_REQUESTS_QUERY = """ +query($query: String!, $after: String) { + search(type: ISSUE, query: $query, first: {PAGE_SIZE}, after: $after) { + nodes { + ... on PullRequest { + id + number + title + url + headRefName + headRefOid + baseRefName + updatedAt + mergedAt + mergeCommit { + oid + } + headRepositoryOwner { + login + } + headRepository { + name + } + reviewThreads(first: {PAGE_SIZE}) { + totalCount + nodes { + isResolved + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +""".replace("{PAGE_SIZE}", str(PAGE_SIZE)) + +UNRESOLVED_THREADS_QUERY = """ +query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + number + title + url + headRefName + headRefOid + reviewThreads(first: {PAGE_SIZE}, after: $after) { + nodes { + id + isResolved + isOutdated + isCollapsed + path + line + diffSide + startLine + startDiffSide + originalLine + originalStartLine + subjectType + comments(first: {PAGE_SIZE}) { + totalCount + nodes { + id + body + createdAt + updatedAt + url + author { + login + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""".replace("{PAGE_SIZE}", str(PAGE_SIZE)) + +REVIEW_THREAD_STATUS_QUERY = """ +query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: {PAGE_SIZE}, after: $after) { + nodes { + isResolved + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""".replace("{PAGE_SIZE}", str(PAGE_SIZE)) + +THREAD_COMMENTS_QUERY = """ +query($threadId: ID!, $after: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: {PAGE_SIZE}, after: $after) { + totalCount + nodes { + id + body + createdAt + updatedAt + url + author { + login + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +} +""".replace("{PAGE_SIZE}", str(PAGE_SIZE)) + +ADD_THREAD_REPLY_MUTATION = """ +mutation($input: AddPullRequestReviewThreadReplyInput!) { + addPullRequestReviewThreadReply(input: $input) { + comment { + id + url + body + } + } +} +""" + +RESOLVE_THREAD_MUTATION = """ +mutation($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + id + isResolved + } + } +} +""" + + +class GitHubGraphQLClient: + def __init__(self, token: str | None = None, session: requests.Session | None = None): + resolved_token = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or gh_auth_token() + if not resolved_token: + raise RuntimeError("Missing GitHub token. Set GH_TOKEN or GITHUB_TOKEN, or authenticate gh.") + self.session = session or requests.Session() + self.session.headers.update( + { + "Accept": "application/vnd.github+json", + "Authorization": f"bearer {resolved_token}", + } + ) + + def execute(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + response = self.session.post( + GRAPHQL_URL, + json={"query": query, "variables": variables or {}}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + errors = payload.get("errors") or [] + if errors: + formatted = "; ".join(error.get("message", "Unknown GitHub GraphQL error") for error in errors) + raise RuntimeError(f"GitHub GraphQL request failed: {formatted}") + return payload["data"] + + +def gh_auth_token() -> str | None: + result = subprocess.run( + ["gh", "auth", "token", "--hostname", "github.com"], + capture_output=True, + text=True, + check=False, + ) + token = result.stdout.strip() + return token or None + + +def authenticated_login() -> str: + result = subprocess.run( + ["gh", "api", "user", "--hostname", "github.com", "--jq", ".login"], + capture_output=True, + text=True, + check=False, + ) + login = result.stdout.strip() + if result.returncode != 0 or not login: + raise RuntimeError("Unable to determine the authenticated GitHub login. Pass --author explicitly or authenticate gh.") + return login + + +def repository_default_branch(owner: str, repo: str) -> str: + result = subprocess.run( + ["gh", "repo", "view", f"github.com/{owner}/{repo}", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + capture_output=True, + text=True, + check=False, + ) + branch = result.stdout.strip() + if result.returncode != 0 or not branch or branch == "null": + raise RuntimeError("Unable to determine the repository default branch. Pass --base explicitly.") + return branch + + +def run_git(args: list[str], cwd: Path | None = None) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +def parse_repo_slug(value: str) -> tuple[str, str]: + parts = value.strip().split("/") + if len(parts) != 2 or not all(parts): + raise argparse.ArgumentTypeError(f"Expected OWNER/REPO, got: {value!r}") + return parts[0], parts[1] + + +def parse_remote_url(url: str) -> tuple[str, str]: + normalized = url.strip() + patterns = ( + r"^https://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?$", + r"^git@github\.com:(?P[^/]+)/(?P[^/]+?)(?:\.git)?$", + ) + for pattern in patterns: + match = re.match(pattern, normalized) + if match: + return match.group("owner"), match.group("repo") + raise RuntimeError("Unable to parse GitHub repository from remote URL. Pass --repo OWNER/REPO explicitly.") + + +def parse_search_date(value: str) -> date: + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError as exc: + raise ValueError(f"Expected YYYY-MM-DD date, got: {value!r}") from exc + + +def build_merged_pull_request_search_query( + owner: str, + repo: str, + author: str, + start_date: date, + end_date: date, + base_ref_name: str = DEFAULT_MERGED_BASE_REF, +) -> str: + search_terms = [ + f"repo:{owner}/{repo}", + "is:pr", + "is:merged", + f"base:{base_ref_name}", + f"author:{author}", + f"merged:{start_date.isoformat()}..{end_date.isoformat()}", + ] + return " ".join(search_terms) + + +def resolve_repo_slug(repo: str | None) -> tuple[str, str]: + if repo: + return parse_repo_slug(repo) + env_repo = None if has_explicit_repo_root() else os.environ.get("GITHUB_REPOSITORY") + if env_repo: + return parse_repo_slug(env_repo) + remote_url = run_git(["remote", "get-url", "origin"], cwd=repo_root()) + return parse_remote_url(remote_url) + + +def normalize_open_pull_request(node: dict[str, Any], owner: str, repo: str) -> dict[str, Any] | None: + head_owner = ((node.get("headRepositoryOwner") or {}).get("login")) or "" + head_repo = ((node.get("headRepository") or {}).get("name")) or "" + same_repository = head_owner == owner and head_repo == repo + if not same_repository: + return None + + thread_nodes = (node.get("reviewThreads") or {}).get("nodes") or [] + total_threads = ((node.get("reviewThreads") or {}).get("totalCount")) or 0 + unresolved_count = sum(1 for thread in thread_nodes if not thread.get("isResolved")) + review_threads_page_info = (node.get("reviewThreads") or {}).get("pageInfo") or {} + return { + "id": node["id"], + "number": node["number"], + "title": node["title"], + "url": node["url"], + "is_draft": node["isDraft"], + "head_ref_name": node["headRefName"], + "head_ref_oid": node["headRefOid"], + "base_ref_name": node["baseRefName"], + "updated_at": node["updatedAt"], + "head_repository_owner": head_owner, + "head_repository_name": head_repo, + "review_thread_count": total_threads, + "unresolved_review_thread_count": unresolved_count, + "review_thread_count_truncated": review_threads_page_info.get("hasNextPage", False), + "review_thread_end_cursor": review_threads_page_info.get("endCursor"), + } + + +def normalize_merged_pull_request(node: dict[str, Any], owner: str, repo: str) -> dict[str, Any] | None: + merged_at = node.get("mergedAt") + if not merged_at: + return None + + head_owner = ((node.get("headRepositoryOwner") or {}).get("login")) or "" + head_repo = ((node.get("headRepository") or {}).get("name")) or "" + thread_nodes = (node.get("reviewThreads") or {}).get("nodes") or [] + total_threads = ((node.get("reviewThreads") or {}).get("totalCount")) or 0 + unresolved_count = sum(1 for thread in thread_nodes if not thread.get("isResolved")) + review_threads_page_info = (node.get("reviewThreads") or {}).get("pageInfo") or {} + return { + "id": node["id"], + "number": node["number"], + "title": node["title"], + "url": node["url"], + "head_ref_name": node.get("headRefName"), + "head_ref_oid": node.get("headRefOid"), + "base_ref_name": node["baseRefName"], + "updated_at": node["updatedAt"], + "merged_at": merged_at, + "merge_commit_oid": (node.get("mergeCommit") or {}).get("oid"), + "head_repository_owner": head_owner, + "head_repository_name": head_repo, + "same_repository": head_owner == owner and head_repo == repo, + "review_thread_count": total_threads, + "unresolved_review_thread_count": unresolved_count, + "review_thread_count_truncated": review_threads_page_info.get("hasNextPage", False), + "review_thread_end_cursor": review_threads_page_info.get("endCursor"), + } + + +def count_unresolved_review_threads( + client: GitHubGraphQLClient, + owner: str, + repo: str, + pull_request_number: int, + initial_unresolved_count: int, + after: str | None, +) -> int: + unresolved_count = initial_unresolved_count + next_cursor = after + + while next_cursor: + data = client.execute( + REVIEW_THREAD_STATUS_QUERY, + { + "owner": owner, + "repo": repo, + "number": pull_request_number, + "after": next_cursor, + }, + ) + pull_request = ((data.get("repository") or {}).get("pullRequest")) or {} + review_threads = pull_request.get("reviewThreads") or {} + unresolved_count += sum(1 for thread in review_threads.get("nodes") or [] if not thread.get("isResolved")) + page_info = review_threads.get("pageInfo") or {} + next_cursor = page_info.get("endCursor") if page_info.get("hasNextPage") else None + + return unresolved_count + + +def list_open_pull_requests( + client: GitHubGraphQLClient, + owner: str, + repo: str, + author: str, +) -> dict[str, Any]: + search_query = f"repo:{owner}/{repo} is:pr is:open author:{author}" + pull_requests: list[dict[str, Any]] = [] + after: str | None = None + + while True: + data = client.execute(OPEN_PULL_REQUESTS_QUERY, {"query": search_query, "after": after}) + search = data["search"] + for node in search.get("nodes") or []: + normalized = normalize_open_pull_request(node, owner, repo) + if normalized is not None: + if normalized["review_thread_count_truncated"]: + normalized["unresolved_review_thread_count"] = count_unresolved_review_threads( + client, + owner, + repo, + normalized["number"], + normalized["unresolved_review_thread_count"], + normalized["review_thread_end_cursor"], + ) + normalized.pop("review_thread_end_cursor", None) + pull_requests.append(normalized) + page_info = search["pageInfo"] + if not page_info["hasNextPage"]: + break + after = page_info["endCursor"] + + pull_requests.sort(key=lambda item: (item["updated_at"], item["number"]), reverse=True) + return { + "repository": f"{owner}/{repo}", + "author": author, + "pull_requests": pull_requests, + } + + +def list_merged_pull_requests( + client: GitHubGraphQLClient, + owner: str, + repo: str, + author: str, + limit: int, + merged_since: str | None = None, + base_ref_name: str = DEFAULT_MERGED_BASE_REF, +) -> dict[str, Any]: + if limit < 1: + raise ValueError("limit must be greater than zero.") + + lower_bound = parse_search_date(merged_since) if merged_since else GITHUB_SEARCH_EARLIEST_DATE + pull_requests: list[dict[str, Any]] = [] + seen_pull_request_ids: set[str] = set() + window_end = datetime.now(timezone.utc).date() + + while window_end >= lower_bound and len(pull_requests) < limit: + window_start = max(lower_bound, window_end - timedelta(days=MERGED_PULL_REQUEST_WINDOW_DAYS - 1)) + search_query = build_merged_pull_request_search_query( + owner, + repo, + author, + window_start, + window_end, + base_ref_name, + ) + after: str | None = None + + while True: + data = client.execute(MERGED_PULL_REQUESTS_QUERY, {"query": search_query, "after": after}) + search = data["search"] + for node in search.get("nodes") or []: + normalized = normalize_merged_pull_request(node, owner, repo) + if normalized is None or normalized["id"] in seen_pull_request_ids: + continue + seen_pull_request_ids.add(normalized["id"]) + if normalized["review_thread_count_truncated"]: + normalized["unresolved_review_thread_count"] = count_unresolved_review_threads( + client, + owner, + repo, + normalized["number"], + normalized["unresolved_review_thread_count"], + normalized["review_thread_end_cursor"], + ) + normalized.pop("review_thread_end_cursor", None) + pull_requests.append(normalized) + page_info = search["pageInfo"] + if not page_info["hasNextPage"]: + break + after = page_info["endCursor"] + + window_end = window_start - timedelta(days=1) + + pull_requests.sort(key=lambda item: (item["merged_at"], item["number"]), reverse=True) + return { + "repository": f"{owner}/{repo}", + "author": author, + "limit": limit, + "merged_since": merged_since, + "pull_requests": pull_requests[:limit], + } + + +def normalize_comment(comment: dict[str, Any]) -> dict[str, Any]: + return { + "id": comment["id"], + "author": ((comment.get("author") or {}).get("login")) or "", + "body": comment["body"], + "created_at": comment["createdAt"], + "updated_at": comment["updatedAt"], + "url": comment["url"], + } + + +def fetch_all_thread_comments( + client: GitHubGraphQLClient, + thread_id: str, + initial_comments: list[dict[str, Any]], + after: str | None, +) -> list[dict[str, Any]]: + comments = list(initial_comments) + next_cursor = after + + while next_cursor: + data = client.execute( + THREAD_COMMENTS_QUERY, + { + "threadId": thread_id, + "after": next_cursor, + }, + ) + thread = data.get("node") + if thread is None: + raise RuntimeError(f"Review thread {thread_id} was not found while fetching comment history.") + comment_connection = thread["comments"] + comments.extend(comment_connection.get("nodes") or []) + page_info = comment_connection.get("pageInfo") or {} + next_cursor = page_info.get("endCursor") if page_info.get("hasNextPage") else None + + return comments + + +def normalize_thread(thread: dict[str, Any], comments: list[dict[str, Any]]) -> dict[str, Any]: + latest_comment = comments[-1] if comments else None + return { + "thread_id": thread["id"], + "path": thread["path"], + "line": thread.get("line"), + "diff_side": thread.get("diffSide"), + "start_line": thread.get("startLine"), + "start_diff_side": thread.get("startDiffSide"), + "original_line": thread.get("originalLine"), + "original_start_line": thread.get("originalStartLine"), + "subject_type": thread.get("subjectType"), + "is_outdated": thread["isOutdated"], + "is_collapsed": thread["isCollapsed"], + "comment_count": len(comments), + "url": latest_comment["url"] if latest_comment else None, + "comments": [normalize_comment(comment) for comment in comments], + } + + +def list_unresolved_threads( + client: GitHubGraphQLClient, + owner: str, + repo: str, + pull_request_number: int, +) -> dict[str, Any]: + threads: list[dict[str, Any]] = [] + pull_request_meta: dict[str, Any] | None = None + after: str | None = None + + while True: + data = client.execute( + UNRESOLVED_THREADS_QUERY, + { + "owner": owner, + "repo": repo, + "number": pull_request_number, + "after": after, + }, + ) + repository = data.get("repository") + if repository is None or repository.get("pullRequest") is None: + raise RuntimeError(f"Pull request #{pull_request_number} was not found in {owner}/{repo}.") + + pull_request = repository["pullRequest"] + if pull_request_meta is None: + pull_request_meta = { + "id": pull_request["id"], + "number": pull_request["number"], + "title": pull_request["title"], + "url": pull_request["url"], + "head_ref_name": pull_request["headRefName"], + "head_ref_oid": pull_request["headRefOid"], + } + + review_threads = pull_request["reviewThreads"] + for thread in review_threads.get("nodes") or []: + if thread["isResolved"]: + continue + comment_connection = thread.get("comments") or {} + comments = comment_connection.get("nodes") or [] + comments_page_info = comment_connection.get("pageInfo") or {} + if comments_page_info.get("hasNextPage"): + comments = fetch_all_thread_comments( + client, thread["id"], comments, comments_page_info.get("endCursor") + ) + threads.append(normalize_thread(thread, comments)) + + page_info = review_threads["pageInfo"] + if not page_info["hasNextPage"]: + break + after = page_info["endCursor"] + + assert pull_request_meta is not None + return { + "repository": f"{owner}/{repo}", + "pull_request": pull_request_meta, + "unresolved_thread_count": len(threads), + "review_threads": threads, + } + + +def build_reply_request(thread_id: str, body: str, resolve: bool) -> tuple[str, dict[str, Any]]: + reply_input = { + "pullRequestReviewThreadId": thread_id, + "body": body, + } + if not resolve: + return ADD_THREAD_REPLY_MUTATION, {"input": reply_input} + return ( + ADD_THREAD_REPLY_MUTATION, + { + "input": reply_input, + }, + ) + + +def reply_to_thread( + client: GitHubGraphQLClient, + thread_id: str, + body: str, + resolve: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + mutation, variables = build_reply_request(thread_id, body, resolve) + if dry_run: + result = { + "thread_id": thread_id, + "body": body, + "resolve": resolve, + "dry_run": True, + "variables": variables, + } + if resolve: + result["resolve_variables"] = {"input": {"threadId": thread_id}} + return result + + data = client.execute(mutation, variables) + result: dict[str, Any] = { + "thread_id": thread_id, + "resolve": resolve, + "dry_run": False, + "reply": data["addPullRequestReviewThreadReply"]["comment"], + } + if resolve: + try: + resolve_data = client.execute(RESOLVE_THREAD_MUTATION, {"input": {"threadId": thread_id}}) + result["thread"] = { + "id": resolve_data["resolveReviewThread"]["thread"]["id"], + "is_resolved": resolve_data["resolveReviewThread"]["thread"]["isResolved"], + } + except Exception as exc: + result["thread"] = { + "id": thread_id, + "is_resolved": False, + } + result["resolve_error"] = str(exc) + return result + + +def read_body_text(args: argparse.Namespace) -> str: + if args.body is not None: + return args.body + if args.body_file == "-": + return sys.stdin.read() + return Path(args.body_file).read_text(encoding="utf-8") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Inspect and update GitHub PR review threads.") + subparsers = parser.add_subparsers(dest="command", required=True) + + list_open_prs = subparsers.add_parser("list-open-prs", help="List same-repo open pull requests by author.") + list_open_prs.add_argument("--repo", help="Repository slug in OWNER/REPO form.") + list_open_prs.add_argument("--author", help="GitHub login. Defaults to the authenticated gh user.") + + list_merged_prs = subparsers.add_parser("list-merged-prs", help="List merged pull requests by author.") + list_merged_prs.add_argument("--repo", help="Repository slug in OWNER/REPO form.") + list_merged_prs.add_argument("--author", help="GitHub login. Defaults to the authenticated gh user.") + list_merged_prs.add_argument("--limit", type=int, default=50) + list_merged_prs.add_argument("--base", help="Base branch. Defaults to the repository's default branch.") + list_merged_prs.add_argument("--merged-since", help="GitHub search date for merged:>=, such as 2026-06-01.") + + list_threads = subparsers.add_parser("list-unresolved-threads", help="List unresolved review threads for a PR.") + list_threads.add_argument("--repo", help="Repository slug in OWNER/REPO form.") + list_threads.add_argument("--pr", type=int, required=True, dest="pull_request_number") + + reply_thread = subparsers.add_parser("reply-thread", help="Reply to a review thread and optionally resolve it.") + reply_thread.add_argument("--thread-id", required=True) + body_group = reply_thread.add_mutually_exclusive_group(required=True) + body_group.add_argument("--body") + body_group.add_argument("--body-file") + reply_thread.add_argument("--resolve", action="store_true") + reply_thread.add_argument("--dry-run", action="store_true") + + return parser + + +def print_json(payload: dict[str, Any]) -> None: + print(json.dumps(payload, indent=2)) + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + if args.command == "reply-thread": + client = GitHubGraphQLClient() + body = read_body_text(args) + print_json(reply_to_thread(client, args.thread_id, body, resolve=args.resolve, dry_run=args.dry_run)) + return 0 + + owner, repo = resolve_repo_slug(getattr(args, "repo", None)) + client = GitHubGraphQLClient() + if args.command in {"list-open-prs", "list-merged-prs"}: + args.author = args.author or authenticated_login() + + if args.command == "list-open-prs": + print_json(list_open_pull_requests(client, owner, repo, args.author)) + return 0 + + if args.command == "list-merged-prs": + base = args.base or repository_default_branch(owner, repo) + print_json(list_merged_pull_requests(client, owner, repo, args.author, args.limit, args.merged_since, base)) + return 0 + + if args.command == "list-unresolved-threads": + print_json(list_unresolved_threads(client, owner, repo, args.pull_request_number)) + return 0 + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + parser.error(f"Unhandled command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentics_tools/tools/preview.py b/src/agentics_tools/tools/preview.py new file mode 100644 index 0000000..c329325 --- /dev/null +++ b/src/agentics_tools/tools/preview.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from .context import repo_root + +MARKER_COMMENT = "" +PREVIEW_ROOT = Path(".github/pr-previews/.local") +PREVIEW_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$") +PREVIEW_VARIANTS = {"targeted", "full-page"} + + +def preview_file_digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def read_preview_metadata(path: Path) -> dict: + if path.is_symlink(): + raise ValueError("Preview metadata must not use symbolic links.") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def write_preview_metadata(path: Path, data: dict) -> None: + if path.is_symlink(): + raise ValueError("Preview metadata must not use symbolic links.") + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + encoding="utf-8", mode="w", dir=path.parent, prefix=f".{path.name}-", delete=False + ) as temporary: + temporary_path = Path(temporary.name) + json.dump(data, temporary, indent=2) + temporary.write("\n") + temporary_path.replace(path) + finally: + if temporary_path: + temporary_path.unlink(missing_ok=True) + + +def capture_source_snapshot() -> dict: + head_sha = run_git(["rev-parse", "HEAD"]).stdout.strip() + status = run_git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]).stdout + fingerprint = hashlib.sha256() + for value in ( + status, + run_git( + [ + "diff", + "--binary", + "--raw", + "--no-abbrev", + "--patch", + "--no-ext-diff", + "--no-textconv", + "--cached", + "HEAD", + "--", + ] + ).stdout, + run_git(["diff", "--binary", "--no-ext-diff", "--no-textconv", "--"]).stdout, + ): + fingerprint.update(value.encode()) + fingerprint.update(b"\0") + untracked = run_git(["ls-files", "--others", "--exclude-standard", "-z"]).stdout + modified = run_git(["ls-files", "--modified", "--deleted", "-z"]).stdout + for relative_path in sorted(set(filter(None, (untracked + modified).split("\0")))): + path = repo_root() / relative_path + fingerprint.update(relative_path.encode()) + fingerprint.update(b"\0") + if not path.exists() and not path.is_symlink(): + fingerprint.update(b"deleted\0") + continue + fingerprint.update(str(path.lstat().st_mode).encode()) + fingerprint.update(b"\0") + content = str(path.readlink()).encode() if path.is_symlink() else path.read_bytes() + fingerprint.update(hashlib.sha256(content).digest()) + return {"head_sha": head_sha, "dirty": bool(status), "source_fingerprint": fingerprint.hexdigest()} + + +def record_preview_capture(preview_files: list[Path], source_snapshot: dict) -> None: + if ( + not isinstance(source_snapshot, dict) + or not isinstance(source_snapshot.get("head_sha"), str) + or not source_snapshot["head_sha"] + or not isinstance(source_snapshot.get("dirty"), bool) + or not isinstance(source_snapshot.get("source_fingerprint"), str) + or not re.fullmatch(r"[0-9a-f]{64}", source_snapshot["source_fingerprint"]) + ): + raise ValueError("Capture requires a source snapshot taken before rendering.") + if capture_source_snapshot() != source_snapshot: + raise RuntimeError("Source changed during capture. Re-capture previews before recording provenance.") + metadata_path = preview_files[0].parent / "captures.json" + metadata = read_preview_metadata(metadata_path) + for path in preview_files: + metadata[path.relative_to(repo_root()).as_posix()] = { + "sha256": preview_file_digest(path), + **source_snapshot, + } + if capture_source_snapshot() != source_snapshot: + raise RuntimeError("Source changed while hashing previews. Re-capture before recording provenance.") + write_preview_metadata(metadata_path, metadata) + + +def load_capture_metadata(preview_files: list[Path], uploaded_digests: dict[str, str] | None = None) -> dict[str, dict]: + metadata_path = preview_files[0].parent / "captures.json" + metadata = read_preview_metadata(metadata_path) + return { + path.relative_to(repo_root()).as_posix(): record + for path in preview_files + if isinstance(record := metadata.get(relative_path := path.relative_to(repo_root()).as_posix()), dict) + and isinstance(record.get("head_sha"), str) + and record["head_sha"] + and isinstance(record.get("dirty"), bool) + and isinstance(record.get("source_fingerprint"), str) + and re.fullmatch(r"[0-9a-f]{64}", record["source_fingerprint"]) + and record.get("sha256") + == (uploaded_digests.get(relative_path) if uploaded_digests is not None else preview_file_digest(path)) + } + + +def run_git(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *args], + cwd=repo_root(), + check=False, + capture_output=True, + text=True, + ) + if check and result.returncode != 0: + raise subprocess.CalledProcessError(result.returncode, ["git", *args], result.stdout, result.stderr) + return result + + +def current_branch_name() -> str: + result = run_git(["symbolic-ref", "--quiet", "--short", "HEAD"], check=False) + branch_name = result.stdout.strip() + if result.returncode != 0 or not branch_name: + raise RuntimeError("Unable to determine the current branch name. Pass --branch explicitly.") + return branch_name + + +def sanitize_branch_name(branch_name: str) -> str: + sanitized = re.sub(r"[^A-Za-z0-9._-]+", "-", branch_name.strip()) + sanitized = re.sub(r"-{2,}", "-", sanitized).strip("-") + if not sanitized or not any(char.isalnum() for char in sanitized): + raise ValueError("Branch name must contain at least one alphanumeric character.") + digest = hashlib.sha256(branch_name.encode()).hexdigest()[:16] + return f"{sanitized[:63].lower()}-{digest}" + + +def validate_preview_id(preview_id: str) -> str: + normalized = preview_id.strip().lower() + if not PREVIEW_ID_PATTERN.fullmatch(normalized): + raise ValueError("preview_id must match ^[a-z0-9][a-z0-9-]*$ and use lowercase letters, digits, or hyphens.") + return normalized + + +def branch_preview_dir(branch_name: str | None = None) -> Path: + resolved_branch = branch_name or current_branch_name() + directory = repo_root() / PREVIEW_ROOT / sanitize_branch_name(resolved_branch) + expected = repo_root().resolve() / PREVIEW_ROOT / sanitize_branch_name(resolved_branch) + if directory.resolve() != expected: + raise ValueError("Preview directories must not use symbolic links.") + return directory + + +def validate_preview_state(state: str | None) -> str | None: + if state is None: + return None + if state not in {"before", "after"}: + raise ValueError("preview state must be 'before' or 'after'.") + return state + + +def validate_preview_variant(variant: str | None) -> str: + if variant is None: + return "targeted" + if variant not in PREVIEW_VARIANTS: + raise ValueError("preview variant must be 'targeted' or 'full-page'.") + return variant + + +def preview_output_path( + preview_id: str, branch_name: str | None = None, state: str | None = None, variant: str | None = None +) -> Path: + resolved_preview_id = validate_preview_id(preview_id) + resolved_state = validate_preview_state(state) + resolved_variant = validate_preview_variant(variant) + suffixes = [resolved_preview_id] + if resolved_state: + suffixes.append(resolved_state) + if resolved_variant == "full-page": + suffixes.append(resolved_variant) + path = branch_preview_dir(branch_name) / f"{'-'.join(suffixes)}.png" + if path.resolve() != path.parent.resolve() / path.name: + raise ValueError("Preview output files must not use symbolic links.") + return path + + +def preview_output_relpath( + preview_id: str, branch_name: str | None = None, state: str | None = None, variant: str | None = None +) -> str: + return preview_output_path(preview_id, branch_name, state, variant).relative_to(repo_root()).as_posix() + + +def list_preview_files(branch_name: str | None = None) -> list[Path]: + directory = branch_preview_dir(branch_name) + if not directory.exists(): + return [] + return sorted(path for path in directory.glob("*.png") if path.is_file()) + + +def clean_preview_dir(branch_name: str | None = None) -> Path: + directory = branch_preview_dir(branch_name) + if directory.exists(): + shutil.rmtree(directory) + return directory + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Resolve and manage branch-scoped frontend PR preview paths.") + subparsers = parser.add_subparsers(dest="command", required=True) + + path_parser = subparsers.add_parser("path", help="Print the repo-relative PNG path for a preview target.") + path_parser.add_argument("preview_id") + path_parser.add_argument("--branch") + path_parser.add_argument("--state", choices=["before", "after"]) + path_parser.add_argument("--variant", choices=sorted(PREVIEW_VARIANTS), default="targeted") + + id_parser = subparsers.add_parser("id", help="Normalize and print a preview target id.") + id_parser.add_argument("preview_id") + + dir_parser = subparsers.add_parser("dir", help="Print the repo-relative directory for the current branch.") + dir_parser.add_argument("--branch") + + list_parser = subparsers.add_parser("list", help="List existing repo-relative PNG paths for the branch.") + list_parser.add_argument("--branch") + list_parser.add_argument("--json", action="store_true") + + clean_parser = subparsers.add_parser("clean", help="Remove the branch-scoped preview directory if it exists.") + clean_parser.add_argument("--branch") + + subparsers.add_parser("snapshot", help="Print source provenance before rendering preview PNGs.") + + record_parser = subparsers.add_parser("record", help="Record the source revision for captured preview PNGs.") + record_parser.add_argument("preview_id") + record_parser.add_argument("--source-snapshot", type=json.loads, required=True) + record_parser.add_argument("--branch") + record_parser.add_argument("--state", choices=["before", "after"]) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + try: + if args.command == "path": + print(preview_output_relpath(args.preview_id, args.branch, args.state, args.variant)) + return 0 + + if args.command == "id": + print(validate_preview_id(args.preview_id)) + return 0 + + if args.command == "dir": + path = branch_preview_dir(args.branch).relative_to(repo_root()).as_posix() + print(path) + return 0 + + if args.command == "list": + items = [path.relative_to(repo_root()).as_posix() for path in list_preview_files(args.branch)] + if args.json: + print(json.dumps(items)) + else: + for item in items: + print(item) + return 0 + + if args.command == "clean": + path = clean_preview_dir(args.branch).relative_to(repo_root()).as_posix() + print(path) + return 0 + + if args.command == "snapshot": + print(json.dumps(capture_source_snapshot(), sort_keys=True)) + return 0 + + if args.command == "record": + record_preview_capture( + [ + preview_output_path(args.preview_id, args.branch, args.state, variant) + for variant in sorted(PREVIEW_VARIANTS) + ], + args.source_snapshot, + ) + return 0 + except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + parser.error(f"Unknown command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentics_tools/tools/preview_sync.py b/src/agentics_tools/tools/preview_sync.py new file mode 100644 index 0000000..37be6bc --- /dev/null +++ b/src/agentics_tools/tools/preview_sync.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib.parse import urlencode, urlsplit + +from .context import has_explicit_repo_root, repo_root +from .preview import ( + MARKER_COMMENT, + current_branch_name, + list_preview_files, + load_capture_metadata, + read_preview_metadata, + write_preview_metadata, +) + +MINIMUM_GH_VERSION = (2, 99, 0) +MAX_IMAGE_BYTES = 10 * 1024 * 1024 + + +def run(args: list[str], *, input_text: str | None = None) -> str: + environment = None + if has_explicit_repo_root(): + environment = dict(os.environ) + environment.pop("GH_REPO", None) + result = subprocess.run( + args, + cwd=repo_root(), + input=input_text, + check=False, + capture_output=True, + text=True, + env=environment, + ) + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}" + raise RuntimeError(f"{' '.join(args)} failed: {details}") + return result.stdout.strip() + + +def gh_json(args: list[str], *, input_text: str | None = None): + host_args = ["--hostname", "github.com"] if args[0] == "api" else [] + stdout = run(["gh", *args, *host_args], input_text=input_text) + return json.loads(stdout) if stdout else None + + +def require_attachment_support() -> None: + match = re.search(r"gh version (\d+)\.(\d+)\.(\d+)", run(["gh", "--version"])) + if not match or tuple(map(int, match.groups())) < MINIMUM_GH_VERSION: + raise RuntimeError("Native PR attachments require gh 2.99.0 or newer. Upgrade GitHub CLI first.") + + +def infer_repo() -> str: + repository = gh_json(["repo", "view", "--json", "nameWithOwner,url"]) + if urlsplit(repository["url"]).hostname != "github.com": + raise RuntimeError("Native PR preview publication supports GitHub.com repositories only.") + return repository["nameWithOwner"] + + +def infer_pr_number(repo: str, branch_name: str | None = None) -> int: + branch = branch_name or current_branch_name() + return int(run(["gh", "pr", "view", branch, "--repo", f"github.com/{repo}", "--json", "number", "--jq", ".number"])) + + +def current_head_sha() -> str: + return run(["git", "rev-parse", "HEAD"]) + + +def validate_preview_files(preview_files: list[Path]) -> None: + for path in preview_files: + try: + info = path.lstat() + except OSError as exc: + raise RuntimeError(f"{path}: cannot read preview PNG.") from exc + if not stat.S_ISREG(info.st_mode) or path.suffix != ".png": + raise RuntimeError(f"{path}: previews must be regular PNG files.") + if not info.st_size: + raise RuntimeError(f"{path}: preview PNG is empty.") + if info.st_size > MAX_IMAGE_BYTES: + raise RuntimeError(f"{path}: GitHub image attachments must be at most 10 MiB.") + with path.open("rb") as image: + if image.read(8) != b"\x89PNG\r\n\x1a\n": + raise RuntimeError(f"{path}: preview is not a PNG image.") + if path.resolve() != repo_root().resolve() / path.relative_to(repo_root()): + raise ValueError("Preview files must not use symbolic links.") + + +def attachment_url(value: str) -> str: + if not isinstance(value, str): + raise RuntimeError("GitHub did not return a permanent native attachment URL.") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or parsed.netloc != "github.com" + or not parsed.path.startswith("/user-attachments/assets/") + or not parsed.path.removeprefix("/user-attachments/assets/") + or parsed.query + or parsed.fragment + ): + raise RuntimeError("GitHub did not return a permanent native attachment URL.") + return value + + +def upload_preview_files( + repo: str, repository_id: int, preview_files: list[Path] +) -> tuple[dict[str, str], dict[str, str]]: + validate_preview_files(preview_files) + cache_path = preview_files[0].parent / "attachments.json" + cache = read_preview_metadata(cache_path) + files = cache.get("files", {}) if cache.get("repo") == repo and cache.get("repository_id") == repository_id else {} + if not isinstance(files, dict): + files = {} + attachments = {} + uploaded_digests = {} + for path in preview_files: + relative_path = path.relative_to(repo_root()).as_posix() + content = path.read_bytes() + if not content.startswith(b"\x89PNG\r\n\x1a\n") or len(content) > MAX_IMAGE_BYTES: + raise RuntimeError(f"{path}: PNG changed during upload preparation. Re-capture and retry.") + digest = hashlib.sha256(content).hexdigest() + uploaded_digests[relative_path] = digest + cached = files.get(relative_path, {}) + if isinstance(cached, dict) and cached.get("sha256") == digest: + try: + url = attachment_url(cached.get("url", "")) + except RuntimeError: + pass + else: + attachments[relative_path] = url + continue + + # Use the same native endpoint as gh --attach, preserving the marker comment's ID. + query = urlencode({"name": path.name, "content_type": "image/png", "repository_id": repository_id}) + with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".bin") as snapshot: + snapshot.write(content) + snapshot.flush() + uploaded = ( + gh_json( + [ + "api", + f"https://uploads.github.com/user-attachments/assets?{query}", + "--method", + "POST", + "--header", + "Content-Type: application/octet-stream", + "--header", + "Accept: application/vnd.github+json", + "--input", + snapshot.name, + ] + ) + or {} + ) + url = attachment_url(uploaded.get("url", "")) + files[relative_path] = {"sha256": digest, "url": url} + write_preview_metadata(cache_path, {"repo": repo, "repository_id": repository_id, "files": files}) + attachments[relative_path] = url + return attachments, uploaded_digests + + +def preview_file_label(preview_path: Path) -> str: + stem = preview_path.stem + variant = "targeted" + if stem.endswith("-full-page"): + stem = stem.removesuffix("-full-page") + variant = "full page" + state = "" + for candidate in ("before", "after"): + if stem.endswith(f"-{candidate}"): + stem = stem.removesuffix(f"-{candidate}") + state = candidate + break + return " ".join(part for part in (stem, state, variant) if part) + + +def build_comment_body( + *, + repo: str, + head_sha: str, + branch_name: str, + attachments: dict[str, str], + captures: dict[str, dict] | None = None, +) -> str: + delimiter = "`" * (max((len(run) for run in re.findall(r"`+", branch_name)), default=0) + 1) + lines = [ + MARKER_COMMENT, + f"Frontend previews for {delimiter} {branch_name} {delimiter}.", + "", + f"PR head: [`{head_sha}`](https://github.com/{repo}/commit/{head_sha}).", + ] + for relative_path, url in attachments.items(): + label = preview_file_label(Path(relative_path)) + lines.extend(["", f"### {label}", ""]) + capture = (captures or {}).get(relative_path) + if capture: + source_sha = capture["head_sha"] + suffix = " (with uncommitted changes)" if capture.get("dirty") else "" + lines.append(f"Captured from [`{source_sha}`](https://github.com/{repo}/commit/{source_sha}){suffix}.") + else: + lines.append("Capture revision unavailable.") + lines.extend(["", f"![{label}]({url})"]) + return "\n".join(lines) + + +def is_marker_comment(comment: dict) -> bool: + return (comment.get("body") or "").split("\n", 1)[0] == MARKER_COMMENT + + +def is_owned_marker(comment: dict, login: str) -> bool: + return is_marker_comment(comment) and comment.get("user", {}).get("login", "").lower() == login.lower() + + +def fetch_marker_comments(repo: str, pr_number: int) -> list[dict]: + page = 1 + markers = [] + while True: + comments = gh_json(["api", f"repos/{repo}/issues/{pr_number}/comments?per_page=100&page={page}"]) or [] + if not comments: + break + markers.extend(comment for comment in comments if is_marker_comment(comment)) + page += 1 + return sorted(markers, key=lambda comment: comment["id"]) + + +def reject_foreign_markers(comments: list[dict], login: str) -> None: + for comment in comments: + if not is_owned_marker(comment, login): + owner = comment.get("user", {}).get("login", "another account") + raise RuntimeError( + f"The PR preview comment is owned by {owner}. Sync as that account; do not create a second marker." + ) + + +def fetch_existing_marker_comment(repo: str, pr_number: int, login: str): + comments = fetch_marker_comments(repo, pr_number) + reject_foreign_markers(comments, login) + return comments[0] if comments else None + + +def validate_current_head(repo: str, pr_number: int, head_sha: str) -> None: + current_pr = gh_json(["api", f"repos/{repo}/pulls/{pr_number}"]) + if current_pr.get("head", {}).get("sha") != head_sha or current_head_sha() != head_sha: + raise RuntimeError("The PR or local head changed during upload. Re-sync previews at the current PR head.") + + +def delete_owned_marker(repo: str, pr_number: int, comment_id: int, login: str, head_sha: str | None = None) -> None: + endpoint = f"repos/{repo}/issues/comments/{comment_id}" + try: + comment = gh_json(["api", endpoint]) + except RuntimeError as exc: + if "(HTTP 404)" in str(exc): + return + raise + if not is_owned_marker(comment, login): + raise RuntimeError("The preview comment changed ownership or marker. No comment was deleted.") + if head_sha is not None: + validate_current_head(repo, pr_number, head_sha) + try: + gh_json(["api", endpoint, "--method", "DELETE"]) + except RuntimeError as exc: + if "(HTTP 404)" not in str(exc): + raise + + +def write_marker_comment( + repo: str, pr_number: int, body: str, login: str, head_sha: str, comment_id: int | None = None +): + endpoint = ( + f"repos/{repo}/issues/comments/{comment_id}" if comment_id else f"repos/{repo}/issues/{pr_number}/comments" + ) + if comment_id is not None: + current = gh_json(["api", endpoint]) + if not is_owned_marker(current, login): + raise RuntimeError("The preview comment changed ownership or marker. No comment was updated.") + validate_current_head(repo, pr_number, head_sha) + return gh_json( + [ + "api", + endpoint, + "--method", + "PATCH" if comment_id else "POST", + "--header", + "Content-Type: application/json", + "--input", + "-", + ], + input_text=json.dumps({"body": body}), + ) + + +def upsert_comment(repo: str, pr_number: int, body: str, login: str, head_sha: str) -> None: + existing = fetch_existing_marker_comment(repo, pr_number, login) + comment = write_marker_comment(repo, pr_number, body, login, head_sha, existing["id"] if existing else None) + comments = fetch_marker_comments(repo, pr_number) + try: + reject_foreign_markers(comments, login) + except RuntimeError: + if not existing: + delete_owned_marker(repo, pr_number, comment["id"], login) + raise + if not comments: + raise RuntimeError("The preview marker disappeared during sync. Retry at the current PR head.") + canonical = comments[0] + if canonical["id"] != comment["id"]: + canonical = write_marker_comment(repo, pr_number, body, login, head_sha, canonical["id"]) + for duplicate in comments[1:]: + delete_owned_marker(repo, pr_number, duplicate["id"], login, head_sha) + print(f"{'Updated' if existing else 'Created'} PR preview comment: {canonical['html_url']}") + + +def delete_comment(repo: str, pr_number: int, login: str) -> None: + comments = [comment for comment in fetch_marker_comments(repo, pr_number) if is_owned_marker(comment, login)] + if not comments: + print("No owned PR preview comment found.") + return + for comment in comments: + delete_owned_marker(repo, pr_number, comment["id"], login) + print(f"Deleted PR preview comment {comment['id']}.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Upload native GitHub attachments and sync the frontend PR preview comment." + ) + parser.add_argument("--repo", help="GitHub.com repository in owner/name form. Defaults to the current gh repo.") + parser.add_argument("--pr", type=int, help="Pull request number. Defaults to the current gh pr.") + parser.add_argument("--branch", help="Preview directory branch name. Defaults to the current git branch.") + parser.add_argument( + "--delete", action="store_true", help="Explicitly delete your marked preview comment; attachments remain." + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + try: + repo = args.repo or infer_repo() + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): + raise RuntimeError("--repo must name a GitHub.com repository in owner/name form.") + pr_number = args.pr or infer_pr_number(repo, args.branch) + if args.delete: + login = gh_json(["api", "user"])["login"] + delete_comment(repo, pr_number, login) + return 0 + + require_attachment_support() + branch_name = args.branch or current_branch_name() + preview_files = list_preview_files(branch_name) + if not preview_files: + raise RuntimeError("No local preview PNGs found. Capture previews first; published previews are unchanged.") + validate_preview_files(preview_files) + repository = gh_json(["api", f"repos/{repo}"]) + if not repository.get("permissions", {}).get("push"): + raise RuntimeError("Native attachment uploads require repository write access.") + pr = gh_json(["api", f"repos/{repo}/pulls/{pr_number}"]) + head_sha = current_head_sha() + if pr.get("head", {}).get("sha") != head_sha: + raise RuntimeError("Local HEAD must match the PR head before publishing previews.") + login = gh_json(["api", "user"])["login"] + fetch_existing_marker_comment(repo, pr_number, login) + attachments, uploaded_digests = upload_preview_files(repo, repository["id"], preview_files) + captures = load_capture_metadata(preview_files, uploaded_digests) + body = build_comment_body( + repo=repo, + head_sha=head_sha, + branch_name=branch_name, + attachments=attachments, + captures=captures, + ) + validate_current_head(repo, pr_number, head_sha) + upsert_comment(repo, pr_number, body, login, head_sha) + return 0 + except (OSError, RuntimeError, subprocess.CalledProcessError, ValueError, KeyError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentics_tools/validation.py b/src/agentics_tools/validation.py new file mode 100644 index 0000000..94159e7 --- /dev/null +++ b/src/agentics_tools/validation.py @@ -0,0 +1,34 @@ +"""Validate packaged references without requiring a particular repository layout.""" + +from __future__ import annotations + +import posixpath +import re +from urllib.parse import unquote, urlsplit + +from .catalog import Catalog + + +def validate(catalog: Catalog): + errors = [] + for key in catalog.items: + bundle = catalog.bundle(key) + for name, (contents, _) in bundle.items(): + if not name.endswith(".md"): + continue + text = contents.decode() + for match in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", text): + target = match.group(1).split(" ", 1)[0].strip("<>") + parts = urlsplit(target) + if parts.scheme or parts.netloc or not parts.path: + continue + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(name), unquote(parts.path))) + if resolved not in bundle and not any(p.startswith(resolved + "/") for p in bundle): + errors.append(f"{key}/{name}: missing bundled link {target}") + if key.startswith("agent:"): + import tomllib + data = tomllib.loads(next(iter(bundle.values()))[0].decode()) + if "model" in data or "model_reasoning_effort" in data: + errors.append(f"{key}: shared agents must inherit model settings") + if errors: + raise ValueError("\n".join(errors)) diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..83f2cb0 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,169 @@ +"""Catalog checks use a small synthetic collection, independent of shipped prompts.""" + +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from agentics_tools.catalog import Catalog + + +class CatalogTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="agentics catalog ") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.items = [ + { + "id": "skill:change-plan", + "kind": "skill", + "name": "change-plan", + "path": "skills/change-plan", + "group": "core", + "dependencies": ["agent:reviewer"], + "resources": {"references/checklist.md": "references/checklist.md"}, + }, + { + "id": "skill:runtime-probe", + "kind": "skill", + "name": "runtime-probe", + "path": "skills/runtime-probe", + "group": "ops", + "dependencies": [], + "resources": {}, + }, + { + "id": "agent:reviewer", + "kind": "agent", + "name": "reviewer", + "path": "agents/reviewer.toml", + "group": "github", + "dependencies": [], + "resources": {}, + }, + ] + for name in ("change-plan", "runtime-probe"): + self.write( + f"skills/{name}/SKILL.md", + f"---\nname: {name}\ndescription: Test {name} workflow.\n---\n\n" + "Follow the current repository instructions.\n", + ) + self.write( + "agents/reviewer.toml", + 'name = "reviewer"\ndescription = "Review changes."\n' + 'developer_instructions = "Inspect changes without editing."\n', + ) + self.write("references/checklist.md", "Check the actual requirements.\n") + + def write(self, relative, content): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + def catalog(self): + self.write( + "catalog.json", + json.dumps({"schema_version": 1, "version": "0.1.0", "items": self.items}), + ) + return Catalog(self.root) + + def test_selects_named_skills_and_transitive_dependencies_once(self): + catalog = self.catalog() + selected = catalog.select( + skills=("change-plan",), agents=("reviewer",), groups=("core",) + ) + self.assertEqual(set(selected), {"skill:change-plan", "agent:reviewer"}) + self.assertEqual(len(selected), 2) + + def test_selects_groups_and_all_items(self): + catalog = self.catalog() + self.assertEqual(catalog.select(groups=("ops",)), ["skill:runtime-probe"]) + self.assertEqual(set(catalog.select(all_items=True)), set(catalog.items)) + + def test_unknown_selection_is_rejected(self): + catalog = self.catalog() + for selection in ( + {"skills": ("absent",)}, + {"agents": ("absent",)}, + {"groups": ("absent",)}, + ): + with self.subTest(selection=selection), self.assertRaises(ValueError): + catalog.select(**selection) + + def test_duplicate_ids_are_rejected(self): + self.items.append(dict(self.items[0])) + with self.assertRaises(ValueError): + self.catalog() + + def test_missing_dependency_is_rejected(self): + self.items[0]["dependencies"] = ["agent:absent"] + with self.assertRaises(ValueError): + self.catalog() + + def test_dependency_cycle_is_rejected(self): + self.items[2]["dependencies"] = ["skill:change-plan"] + with self.assertRaises(ValueError): + self.catalog().select(skills=("change-plan",)) + + def test_source_paths_cannot_escape_catalog(self): + for unsafe in ("../outside", "/tmp/outside"): + with self.subTest(path=unsafe): + self.items[0]["path"] = unsafe + with self.assertRaises(ValueError): + self.catalog() + + def test_resource_destinations_cannot_escape_installed_skill(self): + for unsafe in ("../outside.md", "/tmp/outside.md"): + with self.subTest(path=unsafe): + self.items[0]["resources"] = {unsafe: "references/checklist.md"} + with self.assertRaises(ValueError): + self.catalog() + + def test_resource_sources_cannot_escape_catalog(self): + self.items[0]["resources"] = {"references/checklist.md": "../outside.md"} + with self.assertRaises(ValueError): + self.catalog() + + def test_missing_resource_is_rejected(self): + self.items[0]["resources"] = {"references/checklist.md": "references/absent.md"} + with self.assertRaises(ValueError): + self.catalog() + + def test_resource_cannot_overwrite_skill_instructions(self): + self.items[0]["resources"] = {"SKILL.md": "references/checklist.md"} + with self.assertRaises(ValueError): + self.catalog() + + def test_skill_metadata_must_match_catalog_identity(self): + self.write( + "skills/change-plan/SKILL.md", + "---\nname: wrong-name\ndescription: A different skill.\n---\n", + ) + with self.assertRaises(ValueError): + self.catalog() + + def test_agent_metadata_must_match_catalog_identity(self): + self.write( + "agents/reviewer.toml", + 'name = "other_reviewer"\ndescription = "Review changes."\n' + 'developer_instructions = "Inspect changes without editing."\n', + ) + with self.assertRaises(ValueError): + self.catalog() + + def test_agent_requires_instructions(self): + self.write( + "agents/reviewer.toml", + 'name = "reviewer"\ndescription = "Review changes."\n', + ) + with self.assertRaises(ValueError): + self.catalog() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..433fb9b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import contextlib +import io +import json +import os +from pathlib import Path +import sys +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from agentics_tools.cli import main +import test_installer + + +class CliTests(unittest.TestCase): + def setUp(self): + self.fixture = test_installer.InstallerTests() + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + + def call(self, *arguments): + output, error = io.StringIO(), io.StringIO() + with mock.patch.dict(os.environ, {}, clear=False), mock.patch("agentics_tools.cli.default_catalog", return_value=self.fixture.catalog), contextlib.redirect_stdout(output), contextlib.redirect_stderr(error): + code = main(list(arguments)) + return code, output.getvalue(), error.getvalue() + + def test_list_outputs_machine_readable_catalog(self): + code, output, _ = self.call("list", "--json") + self.assertEqual(code, 0) + self.assertTrue(json.loads(output)) + + def test_install_requires_selection(self): + with self.assertRaises(SystemExit) as failure, contextlib.redirect_stderr(io.StringIO()): + self.call("install", "--home", str(self.fixture.layout.home)) + self.assertEqual(failure.exception.code, 2) + + def test_cli_dry_run_does_not_create_home(self): + home = self.fixture.layout.home / "unused" + code, _, error = self.call("install", "--all", "--dry-run", "--home", str(home), "--codex-home", str(home / ".codex")) + self.assertEqual(code, 0, error) + self.assertFalse(home.exists()) + + def test_all_update_does_not_hide_colliding_owned_role(self): + from agentics_tools.installer import install + install(self.fixture.catalog, self.fixture.layout, self.fixture.catalog.select(all_items=True)) + agent_item = next(item for item in self.fixture.catalog.items.values() if item["kind"] == "agent") + source = self.fixture.catalog.root / agent_item["path"] + (self.fixture.layout.agent_root / "collision.toml").write_bytes(source.read_bytes()) + code, _, error = self.call("update", "--all", "--home", str(self.fixture.layout.home), "--codex-home", str(self.fixture.layout.codex_home)) + self.assertEqual(code, 1) + self.assertIn("collision", error) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..079c102 --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,436 @@ +"""Exercise installation ownership and failures entirely in temporary homes.""" + +import json +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest import mock + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from agentics_tools.catalog import Catalog +from agentics_tools import installer +from agentics_tools.installer import Layout, install, status, uninstall, update + + +class InstallerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="agentics installer ") + self.addCleanup(self.temporary.cleanup) + self.base = Path(self.temporary.name).resolve() + self.root = self.base / "catalog source" + self.home = self.base / "user home" + self.layout = Layout(self.home) + self.items = [ + { + "id": "skill:change-plan", + "kind": "skill", + "name": "change-plan", + "path": "skills/change-plan", + "group": "core", + "dependencies": ["agent:reviewer"], + "resources": { + "references/checklist.md": "references/checklist.md", + "scripts/check.py": "references/check.py", + }, + }, + { + "id": "skill:runtime-probe", + "kind": "skill", + "name": "runtime-probe", + "path": "skills/runtime-probe", + "group": "ops", + "dependencies": [], + "resources": {}, + }, + { + "id": "agent:reviewer", + "kind": "agent", + "name": "reviewer", + "path": "agents/reviewer.toml", + "group": "github", + "dependencies": [], + "resources": {}, + }, + ] + for name in ("change-plan", "runtime-probe"): + self.write( + f"skills/{name}/SKILL.md", + f"---\nname: {name}\ndescription: Test {name} workflow.\n---\n\n" + "Follow the current repository instructions.\n", + ) + self.write( + "agents/reviewer.toml", + 'name = "reviewer"\ndescription = "Review changes."\n' + 'developer_instructions = "Inspect changes without editing."\n', + ) + self.write("references/checklist.md", "Check the actual requirements.\n") + self.write("references/check.py", "#!/usr/bin/env python3\nprint('checked')\n") + (self.root / "references/check.py").chmod(0o755) + self.catalog = self.reload_catalog() + self.all_ids = self.catalog.select(all_items=True) + + def write(self, relative, content): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + def reload_catalog(self): + self.write( + "catalog.json", + json.dumps({"schema_version": 1, "version": "0.1.0", "items": self.items}), + ) + return Catalog(self.root) + + def statuses(self, ids=None): + return {entry["id"]: entry["status"] for entry in status(self.catalog, self.layout, ids)} + + def snapshot(self, path): + if not path.exists(): + return None + return { + str(entry.relative_to(path)): ( + "symlink", + str(entry.readlink()), + ) + if entry.is_symlink() + else (entry.read_bytes(), stat.S_IMODE(entry.stat().st_mode)) + for entry in path.rglob("*") + if entry.is_file() or entry.is_symlink() + } + + def test_install_bundles_resources_and_preserves_executability(self): + install(self.catalog, self.layout, self.all_ids) + skill = self.layout.skill_root / "change-plan" + self.assertEqual( + (skill / "references/checklist.md").read_text(), + "Check the actual requirements.\n", + ) + self.assertTrue((skill / "scripts/check.py").stat().st_mode & stat.S_IXUSR) + self.assertTrue((self.layout.agent_root / "reviewer.toml").is_file()) + self.assertTrue(self.layout.state_path.is_file()) + self.assertEqual(set(self.statuses().values()), {"current"}) + + def test_repeated_install_is_idempotent(self): + install(self.catalog, self.layout, self.all_ids) + before = self.snapshot(self.home) + install(self.catalog, self.layout, self.all_ids) + self.assertEqual(self.snapshot(self.home), before) + + def test_initial_dry_run_creates_no_home_or_manifest(self): + actions = install(self.catalog, self.layout, self.all_ids, dry_run=True) + self.assertEqual({action["id"] for action in actions}, set(self.all_ids)) + self.assertFalse(self.home.exists()) + + def test_status_is_read_only_before_installation(self): + self.assertEqual(set(self.statuses(self.all_ids).values()), {"not-installed"}) + self.assertFalse(self.home.exists()) + + def test_skill_collision_preflights_all_items_without_writes(self): + collision = self.layout.skill_root / "runtime-probe" + collision.mkdir(parents=True) + (collision / "SKILL.md").write_text("User-owned workflow.\n") + before = self.snapshot(self.home) + with self.assertRaises(ValueError): + install(self.catalog, self.layout, self.all_ids) + self.assertEqual(self.snapshot(self.home), before) + self.assertEqual(self.statuses(["skill:runtime-probe"])["skill:runtime-probe"], "collision") + + def test_role_name_collision_under_different_filename_is_rejected(self): + self.layout.agent_root.mkdir(parents=True) + (self.layout.agent_root / "my-own-agent.toml").write_text( + 'name = "reviewer"\ndescription = "My reviewer."\n' + 'developer_instructions = "Review my changes."\n' + ) + before = self.snapshot(self.home) + with self.assertRaises(ValueError): + install(self.catalog, self.layout, self.all_ids) + self.assertEqual(self.snapshot(self.home), before) + + def test_explicit_global_role_registration_is_a_collision(self): + codex_home = self.layout.agent_root.parent + codex_home.mkdir(parents=True) + (codex_home / "config.toml").write_text( + '[agents.reviewer]\ndescription = "My reviewer."\n' + 'config_file = "elsewhere/my-reviewer.toml"\n' + ) + before = self.snapshot(self.home) + with self.assertRaises(ValueError): + install(self.catalog, self.layout, self.all_ids) + self.assertEqual(self.snapshot(self.home), before) + + def test_modified_skill_prevents_update_and_uninstall(self): + install(self.catalog, self.layout, self.all_ids) + installed = self.layout.skill_root / "change-plan/SKILL.md" + installed.write_text(installed.read_text() + "Local customization.\n") + self.assertEqual(self.statuses()["skill:change-plan"], "modified") + before = self.snapshot(self.home) + for operation in (update, uninstall): + with self.subTest(operation=operation.__name__), self.assertRaises(ValueError): + operation(self.catalog, self.layout, self.all_ids) + self.assertEqual(self.snapshot(self.home), before) + + def test_added_missing_or_mode_changed_files_are_local_modifications(self): + changes = ("extra", "missing", "mode") + for index, change in enumerate(changes): + with self.subTest(change=change): + layout = Layout(self.base / f"variant home {index}") + install(self.catalog, layout, ["skill:runtime-probe"]) + destination = layout.skill_root / "runtime-probe" + if change == "extra": + (destination / "local-notes.md").write_text("Keep my notes.\n") + elif change == "missing": + (destination / "SKILL.md").unlink() + else: + (destination / "SKILL.md").chmod(0o755) + before = self.snapshot(layout.skill_root.parent) + self.assertEqual( + status(self.catalog, layout, ["skill:runtime-probe"])[0]["status"], + "modified", + ) + with self.assertRaises(ValueError): + uninstall(self.catalog, layout, ["skill:runtime-probe"]) + self.assertEqual(self.snapshot(layout.skill_root.parent), before) + + def test_update_replaces_only_selected_installed_items(self): + install(self.catalog, self.layout, self.all_ids) + old_probe = (self.layout.skill_root / "runtime-probe/SKILL.md").read_bytes() + self.write("references/checklist.md", "An updated checklist.\n") + path = self.root / "skills/runtime-probe/SKILL.md" + path.write_text(path.read_text() + "Updated probe guidance.\n") + self.catalog = self.reload_catalog() + self.assertEqual(self.statuses()["skill:change-plan"], "update-available") + update(self.catalog, self.layout, ["skill:change-plan"]) + self.assertEqual( + (self.layout.skill_root / "change-plan/references/checklist.md").read_text(), + "An updated checklist.\n", + ) + self.assertEqual((self.layout.skill_root / "runtime-probe/SKILL.md").read_bytes(), old_probe) + self.assertEqual(self.statuses()["skill:runtime-probe"], "update-available") + + def test_role_source_filename_change_keeps_installed_state_consistent(self): + install(self.catalog, self.layout, ["agent:reviewer"]) + original = self.root / "agents/reviewer.toml" + original.rename(self.root / "agents/renamed-reviewer.toml") + self.items[2]["path"] = "agents/renamed-reviewer.toml" + self.catalog = self.reload_catalog() + update(self.catalog, self.layout, ["agent:reviewer"]) + self.assertEqual(self.statuses(["agent:reviewer"])["agent:reviewer"], "current") + definitions = list(self.layout.agent_root.glob("*.toml")) + self.assertEqual(len(definitions), 1) + uninstall(self.catalog, self.layout, ["agent:reviewer"]) + self.assertFalse(list(self.layout.agent_root.glob("*.toml"))) + + def test_update_does_not_install_new_group_members(self): + install(self.catalog, self.layout, ["skill:runtime-probe"]) + try: + update(self.catalog, self.layout, self.all_ids) + except ValueError: + pass + self.assertFalse((self.layout.skill_root / "change-plan").exists()) + self.assertFalse(self.layout.agent_root.exists()) + + def test_catalog_group_changes_do_not_remove_installed_items(self): + install(self.catalog, self.layout, self.all_ids) + self.items[1]["group"] = "maintenance" + self.catalog = self.reload_catalog() + update(self.catalog, self.layout, self.catalog.select(groups=("core",))) + self.assertTrue((self.layout.skill_root / "runtime-probe/SKILL.md").is_file()) + + def test_update_refuses_removed_catalog_item_without_writes(self): + install(self.catalog, self.layout, self.all_ids) + self.items = [item for item in self.items if item["id"] != "skill:runtime-probe"] + self.catalog = self.reload_catalog() + before = self.snapshot(self.home) + self.assertEqual(self.statuses()["skill:runtime-probe"], "source-unavailable") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex( + ValueError, "Operation refused before writing:.*\n.*source unavailable" + ): + update(self.catalog, self.layout, self.all_ids, dry_run=dry_run) + self.assertEqual(self.snapshot(self.home), before) + + def test_update_and_uninstall_dry_run_leave_existing_state_unchanged(self): + install(self.catalog, self.layout, self.all_ids) + self.write("references/checklist.md", "An updated checklist.\n") + self.catalog = self.reload_catalog() + before = self.snapshot(self.home) + update(self.catalog, self.layout, self.all_ids, dry_run=True) + self.assertEqual(self.snapshot(self.home), before) + uninstall(self.catalog, self.layout, self.all_ids, dry_run=True) + self.assertEqual(self.snapshot(self.home), before) + + def test_failed_replacement_restores_all_managed_items_and_state(self): + install(self.catalog, self.layout, self.all_ids) + before = self.snapshot(self.home) + self.write("references/checklist.md", "An updated checklist.\n") + probe = self.root / "skills/runtime-probe/SKILL.md" + probe.write_text(probe.read_text() + "Updated probe guidance.\n") + self.catalog = self.reload_catalog() + original_rename = Path.rename + + def fail_second_replacement(path, target): + if path.name == "payload" and Path(target).name == "runtime-probe": + raise OSError("Simulated destination failure") + return original_rename(path, target) + + with mock.patch.object(Path, "rename", fail_second_replacement): + with self.assertRaises(OSError): + update(self.catalog, self.layout, self.all_ids) + self.assertEqual(self.snapshot(self.home), before) + + def test_incomplete_rollback_preserves_backups_and_restores_other_items(self): + for failure in ("remove", "rename"): + with self.subTest(failure=failure): + layout = Layout(self.base / f"recovery home {failure}") + install(self.catalog, layout, self.all_ids) + change_plan = layout.skill_root / "change-plan" + runtime_probe = layout.skill_root / "runtime-probe" + before_change_plan = self.snapshot(change_plan) + before_probe = self.snapshot(runtime_probe) + before_state = layout.state_path.read_bytes() + checklist = self.root / "references/checklist.md" + checklist.write_text(checklist.read_text() + "Updated checklist.\n") + probe_source = self.root / "skills/runtime-probe/SKILL.md" + probe_source.write_text(probe_source.read_text() + "Updated probe.\n") + self.catalog = self.reload_catalog() + original_remove = installer._remove + original_rename = Path.rename + + def fail_state_save(path, target): + raise OSError("Simulated state save failure") + + def fail_remove(path): + if failure == "remove" and path == runtime_probe: + raise OSError("Simulated rollback removal failure") + return original_remove(path) + + def fail_restore(path, target): + if failure == "rename" and path.name == "backup" and target == runtime_probe: + raise OSError("Simulated rollback rename failure") + return original_rename(path, target) + + with ( + mock.patch.object(Path, "replace", fail_state_save), + mock.patch.object(installer, "_remove", fail_remove), + mock.patch.object(Path, "rename", fail_restore), + self.assertRaisesRegex(RuntimeError, "rollback incomplete") as error, + ): + update(self.catalog, layout, self.all_ids) + + self.assertEqual(self.snapshot(change_plan), before_change_plan) + self.assertEqual(layout.state_path.read_bytes(), before_state) + backups = list(layout.skill_root.glob(".agentics-*/backup")) + self.assertEqual(len(backups), 1) + self.assertEqual(self.snapshot(backups[0]), before_probe) + self.assertIn(str(backups[0].parent), str(error.exception)) + lock = layout.state_path.with_suffix(".lock") + self.assertTrue(lock.exists()) + self.assertIn(str(lock), str(error.exception)) + + def test_state_temporary_path_cannot_overwrite_unrelated_symlink_target(self): + install(self.catalog, self.layout, ["skill:runtime-probe"]) + outside = self.base / "unrelated-private-file.txt" + outside.write_text("Preserve this unrelated file.\n") + temporary_state = self.layout.state_path.with_suffix(".tmp") + temporary_state.symlink_to(outside) + source = self.root / "skills/runtime-probe/SKILL.md" + source.write_text(source.read_text() + "Updated probe guidance.\n") + self.catalog = self.reload_catalog() + try: + update(self.catalog, self.layout, ["skill:runtime-probe"]) + except ValueError: + pass + self.assertEqual(outside.read_text(), "Preserve this unrelated file.\n") + self.assertTrue(temporary_state.is_symlink()) + + def test_uninstall_cannot_leave_installed_dependent_without_required_agent(self): + install(self.catalog, self.layout, self.all_ids) + before = self.snapshot(self.home) + with self.assertRaises(ValueError): + uninstall(self.catalog, self.layout, ["agent:reviewer"]) + self.assertEqual(self.snapshot(self.home), before) + + def test_dependency_metadata_updates_are_used_for_uninstall_protection(self): + install(self.catalog, self.layout, self.all_ids) + self.items[0]["dependencies"].append("skill:runtime-probe") + self.catalog = self.reload_catalog() + update(self.catalog, self.layout, self.all_ids) + before = self.snapshot(self.home) + with self.assertRaises(ValueError): + uninstall(self.catalog, self.layout, ["skill:runtime-probe"]) + self.assertEqual(self.snapshot(self.home), before) + + def test_uninstall_preserves_unrelated_skills_agents_and_config(self): + install(self.catalog, self.layout, self.all_ids) + unrelated_skill = self.layout.skill_root / "personal/SKILL.md" + unrelated_skill.parent.mkdir() + unrelated_skill.write_text("My skill.\n") + unrelated_agent = self.layout.agent_root / "personal.toml" + unrelated_agent.write_text('name = "personal"\n') + config = self.layout.agent_root.parent / "config.toml" + config.write_text('model = "my-model"\n') + uninstall(self.catalog, self.layout, self.all_ids) + self.assertEqual(unrelated_skill.read_text(), "My skill.\n") + self.assertEqual(unrelated_agent.read_text(), 'name = "personal"\n') + self.assertEqual(config.read_text(), 'model = "my-model"\n') + self.assertFalse((self.layout.skill_root / "change-plan").exists()) + self.assertFalse((self.layout.agent_root / "reviewer.toml").exists()) + + def test_custom_codex_home_keeps_standard_global_skill_location(self): + custom = self.base / "custom codex configuration" + layout = Layout(self.home, custom) + install(self.catalog, layout, self.all_ids) + self.assertEqual(layout.skill_root, self.home / ".agents/skills") + self.assertEqual(layout.agent_root, custom / "agents") + self.assertEqual(layout.state_path, custom / "agentics/installed.json") + self.assertTrue((custom / "agents/reviewer.toml").is_file()) + self.assertFalse((self.home / ".codex").exists()) + + def test_missing_installation_directory_is_reported_and_not_recreated_by_status(self): + install(self.catalog, self.layout, ["skill:runtime-probe"]) + installed = self.layout.skill_root / "runtime-probe" + (installed / "SKILL.md").unlink() + installed.rmdir() + self.assertEqual(self.statuses(["skill:runtime-probe"])["skill:runtime-probe"], "missing") + self.assertFalse(installed.exists()) + + def test_symlinked_install_destination_is_never_followed(self): + outside = self.base / "outside" + outside.mkdir() + marker = outside / "keep.txt" + marker.write_text("Leave this directory alone.\n") + self.layout.skill_root.mkdir(parents=True) + (self.layout.skill_root / "runtime-probe").symlink_to(outside, target_is_directory=True) + with self.assertRaises(ValueError): + install(self.catalog, self.layout, ["skill:runtime-probe"]) + self.assertEqual(marker.read_text(), "Leave this directory alone.\n") + self.assertEqual(set(outside.iterdir()), {marker}) + + def test_symlink_added_to_managed_skill_blocks_update(self): + install(self.catalog, self.layout, ["skill:runtime-probe"]) + outside = self.base / "private.txt" + outside.write_text("Private content.\n") + (self.layout.skill_root / "runtime-probe/local-link").symlink_to(outside) + with self.assertRaises(ValueError): + update(self.catalog, self.layout, ["skill:runtime-probe"]) + self.assertEqual(outside.read_text(), "Private content.\n") + + def test_symlinked_catalog_resource_is_rejected(self): + target = self.base / "outside-reference.md" + target.write_text("Not part of this collection.\n") + resource = self.root / "references/checklist.md" + resource.unlink() + resource.symlink_to(target) + with self.assertRaises(ValueError): + catalog = self.reload_catalog() + install(catalog, self.layout, ["skill:change-plan"]) + self.assertFalse(self.home.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pr_threads.py b/tests/test_pr_threads.py new file mode 100644 index 0000000..d8e158e --- /dev/null +++ b/tests/test_pr_threads.py @@ -0,0 +1,897 @@ +import io +import os +import subprocess +from contextlib import redirect_stderr, redirect_stdout +import sys +from pathlib import Path +from unittest import mock + +from unittest import TestCase + +from agentics_tools.tools import context, pr_threads + +class PullRequestReviewThreadsScriptTests(TestCase): + def setUp(self): + self.module = pr_threads + context.set_repo_root(None) + self.addCleanup(context.set_repo_root, None) + + def test_token_lookup_uses_graphql_host_despite_enterprise_default(self): + session = mock.Mock() + session.headers = {} + session.post.return_value.json.return_value = {"data": {"viewer": {"login": "contributor"}}} + with ( + mock.patch.dict(os.environ, {"GH_HOST": "enterprise.example"}, clear=True), + mock.patch.object( + self.module.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, "synthetic-token\n", ""), + ) as run, + ): + client = self.module.GitHubGraphQLClient(session=session) + client.execute("query { viewer { login } }") + + self.assertEqual(run.call_args.args[0], ["gh", "auth", "token", "--hostname", "github.com"]) + self.assertEqual(session.post.call_args.args[0], "https://api.github.com/graphql") + self.assertEqual(session.headers["Authorization"], "bearer synthetic-token") + + def test_author_defaults_to_authenticated_github_login(self): + with mock.patch.object( + self.module.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, "someone-new\n", ""), + ) as run: + self.assertEqual(self.module.authenticated_login(), "someone-new") + self.assertEqual(run.call_args.args[0], ["gh", "api", "user", "--hostname", "github.com", "--jq", ".login"]) + + def test_missing_authenticated_login_explains_author_override(self): + with mock.patch.object( + self.module.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 1, "", "not authenticated"), + ): + with self.assertRaisesRegex(RuntimeError, "Pass --author explicitly"): + self.module.authenticated_login() + + def test_list_commands_resolve_default_author_and_preserve_explicit_author(self): + for command in ("list-open-prs", "list-merged-prs"): + operation = "list_open_pull_requests" if command == "list-open-prs" else "list_merged_pull_requests" + for author in (None, "explicit-author"): + with self.subTest(command=command, author=author): + argv = ["pr-threads", command, "--repo", "example/repository"] + if author: + argv.extend(["--author", author]) + with ( + mock.patch.object(sys, "argv", argv), + mock.patch.object(self.module, "GitHubGraphQLClient"), + mock.patch.object(self.module, "authenticated_login", return_value="current-login") as login, + mock.patch.object(self.module, "repository_default_branch", return_value="main"), + mock.patch.object(self.module, operation, return_value={"pull_requests": []}) as listing, + redirect_stdout(io.StringIO()), + ): + self.assertEqual(self.module.main(), 0) + self.assertEqual(listing.call_args.args[3], author or "current-login") + self.assertEqual(login.call_count, 0 if author else 1) + + def test_merged_cli_discovers_default_base_and_preserves_override(self): + for explicit_base in (None, "release"): + argv = ["pr-threads", "list-merged-prs", "--repo", "example/repository", "--author", "contributor"] + if explicit_base: + argv.extend(["--base", explicit_base]) + with ( + mock.patch.object(sys, "argv", argv), + mock.patch.object(self.module, "GitHubGraphQLClient"), + mock.patch.object(self.module, "repository_default_branch", return_value="develop") as default, + mock.patch.object(self.module, "list_merged_pull_requests", return_value={}) as listing, + redirect_stdout(io.StringIO()), + ): + self.assertEqual(self.module.main(), 0) + self.assertEqual(listing.call_args.args[6], explicit_base or "develop") + self.assertEqual(default.call_count, 0 if explicit_base else 1) + + def test_default_branch_query_binds_repository_and_rejects_empty_defaults(self): + for stdout, returncode in (("develop\n", 0), ("", 1), ("null\n", 0)): + with self.subTest(stdout=stdout, returncode=returncode): + with mock.patch.object( + self.module.subprocess, + "run", + return_value=subprocess.CompletedProcess([], returncode, stdout, ""), + ) as run: + if stdout.strip() == "develop": + self.assertEqual(self.module.repository_default_branch("example", "repository"), "develop") + else: + with self.assertRaisesRegex(RuntimeError, "Pass --base explicitly"): + self.module.repository_default_branch("example", "repository") + self.assertEqual(run.call_args.args[0][3], "github.com/example/repository") + + def test_merged_search_uses_selected_base(self): + client = mock.Mock() + client.execute.return_value = {"search": {"nodes": [], "pageInfo": {"hasNextPage": False}}} + today = self.module.datetime.now(self.module.timezone.utc).date().isoformat() + self.module.list_merged_pull_requests(client, "example", "repository", "contributor", 1, today, "develop") + self.assertIn("base:develop", client.execute.call_args.args[1]["query"]) + + def test_selected_checkout_origin_overrides_ambient_ci_repository(self): + with ( + mock.patch.object(self.module, "has_explicit_repo_root", return_value=True), + mock.patch.object(self.module, "repo_root", return_value=Path("/selected/repository")), + mock.patch.object(self.module, "run_git", return_value="git@github.com:selected/repository.git") as git, + mock.patch.dict(os.environ, {"GITHUB_REPOSITORY": "ambient/wrong-repo"}), + ): + self.assertEqual(self.module.resolve_repo_slug(None), ("selected", "repository")) + git.assert_called_once_with(["remote", "get-url", "origin"], cwd=Path("/selected/repository")) + + def test_explicit_repo_flag_overrides_selected_checkout_and_ci_environment(self): + with ( + mock.patch.object(self.module, "has_explicit_repo_root", return_value=True), + mock.patch.object(self.module, "run_git") as git, + mock.patch.dict(os.environ, {"GITHUB_REPOSITORY": "ambient/wrong-repo"}), + ): + self.assertEqual(self.module.resolve_repo_slug("requested/repository"), ("requested", "repository")) + git.assert_not_called() + + def test_ambient_ci_repository_is_retained_without_explicit_checkout(self): + with ( + mock.patch.object(self.module, "has_explicit_repo_root", return_value=False), + mock.patch.object(self.module, "run_git") as git, + mock.patch.dict(os.environ, {"GITHUB_REPOSITORY": "ci/repository"}), + ): + self.assertEqual(self.module.resolve_repo_slug(None), ("ci", "repository")) + git.assert_not_called() + + def test_parse_remote_url_supports_https_and_ssh(self): + https_actual = self.module.parse_remote_url("https://github.com/example-org/example-repo.git") + ssh_actual = self.module.parse_remote_url("git@github.com:example-org/example-repo.git") + + https_expected = ("example-org", "example-repo") + ssh_expected = ("example-org", "example-repo") + + self.assertEqual(https_actual, https_expected) + self.assertEqual(ssh_actual, ssh_expected) + + def test_cli_does_not_print_credentials_from_rejected_remote_urls(self): + credential = "synthetic-credential-sentinel" + for userinfo in (f"account:{credential}", credential): + remote = f"https://{userinfo}@github.com/example-org/example-repo.git" + output, error = io.StringIO(), io.StringIO() + with ( + self.subTest(userinfo=userinfo), + mock.patch.object(sys, "argv", ["pr-threads", "list-open-prs"]), + mock.patch.object(self.module, "has_explicit_repo_root", return_value=True), + mock.patch.object(self.module, "repo_root", return_value=Path("/selected/repository")), + mock.patch.object(self.module, "run_git", return_value=remote), + mock.patch.object(self.module, "GitHubGraphQLClient") as client, + redirect_stdout(output), + redirect_stderr(error), + ): + self.assertEqual(self.module.main(), 1) + self.assertIn("Pass --repo OWNER/REPO explicitly", error.getvalue()) + self.assertNotIn(credential, error.getvalue() + output.getvalue()) + self.assertNotIn(remote, error.getvalue() + output.getvalue()) + client.assert_not_called() + + def test_list_open_pull_requests_filters_forks_and_counts_unresolved_threads(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "search": { + "nodes": [ + { + "id": "pr_1", + "number": 101, + "title": "Recent", + "url": "https://example.com/pr/101", + "isDraft": False, + "headRefName": "feature-recent", + "headRefOid": "sha-recent", + "baseRefName": "main", + "updatedAt": "2026-04-11T00:00:00Z", + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 2, + "nodes": [ + {"isResolved": False}, + {"isResolved": True}, + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + }, + { + "id": "pr_2", + "number": 102, + "title": "Fork", + "url": "https://example.com/pr/102", + "isDraft": False, + "headRefName": "fork-branch", + "headRefOid": "sha-fork", + "baseRefName": "main", + "updatedAt": "2026-04-10T00:00:00Z", + "headRepositoryOwner": {"login": "someone-else"}, + "headRepository": {"name": "forked-repo"}, + "reviewThreads": { + "totalCount": 1, + "nodes": [{"isResolved": False}], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + }, + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "cursor-1", + }, + } + }, + { + "search": { + "nodes": [ + { + "id": "pr_3", + "number": 100, + "title": "Older", + "url": "https://example.com/pr/100", + "isDraft": True, + "headRefName": "feature-older", + "headRefOid": "sha-older", + "baseRefName": "main", + "updatedAt": "2026-04-09T00:00:00Z", + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + }, + ] + + actual = self.module.list_open_pull_requests(fake_client, "example-org", "example-repo", "contributor") + + expected_numbers = [101, 100] + actual_numbers = [pull_request["number"] for pull_request in actual["pull_requests"]] + + self.assertEqual(actual["repository"], "example-org/example-repo") + self.assertEqual(actual["author"], "contributor") + self.assertEqual(actual_numbers, expected_numbers) + self.assertEqual(actual["pull_requests"][0]["unresolved_review_thread_count"], 1) + + def test_list_open_pull_requests_fetches_remaining_thread_pages_for_exact_count(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "search": { + "nodes": [ + { + "id": "pr_1", + "number": 101, + "title": "Recent", + "url": "https://example.com/pr/101", + "isDraft": False, + "headRefName": "feature-recent", + "headRefOid": "sha-recent", + "baseRefName": "main", + "updatedAt": "2026-04-11T00:00:00Z", + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 101, + "nodes": [ + {"isResolved": True}, + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "thread-page-1", + }, + }, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + }, + { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [ + {"isResolved": False}, + {"isResolved": False}, + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + } + } + }, + ] + + actual = self.module.list_open_pull_requests(fake_client, "example-org", "example-repo", "contributor") + + self.assertEqual(actual["pull_requests"][0]["unresolved_review_thread_count"], 2) + self.assertTrue(actual["pull_requests"][0]["review_thread_count_truncated"]) + + def test_build_merged_pull_request_search_query_targets_main_date_window(self): + actual = self.module.build_merged_pull_request_search_query( + "example-org", + "example-repo", + "contributor", + self.module.parse_search_date("2026-04-01"), + self.module.parse_search_date("2026-04-30"), + ) + + self.assertIn("repo:example-org/example-repo", actual) + self.assertIn("is:merged", actual) + self.assertIn("base:main", actual) + self.assertIn("author:contributor", actual) + self.assertIn("merged:2026-04-01..2026-04-30", actual) + + def test_list_merged_pull_requests_includes_forks_and_counts_unresolved_threads(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "search": { + "nodes": [ + { + "id": "pr_201", + "number": 201, + "title": "Merged same repo", + "url": "https://example.com/pr/201", + "headRefName": "merged-same-repo", + "headRefOid": "sha-201", + "baseRefName": "main", + "updatedAt": "2026-04-11T00:00:00Z", + "mergedAt": "2026-04-11T01:00:00Z", + "mergeCommit": {"oid": "merge-sha-201"}, + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 2, + "nodes": [ + {"isResolved": False}, + {"isResolved": True}, + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + }, + { + "id": "pr_202", + "number": 202, + "title": "Merged fork", + "url": "https://example.com/pr/202", + "headRefName": "merged-fork", + "headRefOid": "sha-202", + "baseRefName": "main", + "updatedAt": "2026-04-10T00:00:00Z", + "mergedAt": "2026-04-10T01:00:00Z", + "mergeCommit": {"oid": "merge-sha-202"}, + "headRepositoryOwner": {"login": "someone-else"}, + "headRepository": {"name": "forked-repo"}, + "reviewThreads": { + "totalCount": 101, + "nodes": [ + {"isResolved": False}, + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "thread-page-1", + }, + }, + }, + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + }, + { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [ + {"isResolved": False}, + {"isResolved": True}, + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + } + } + }, + ] + + actual = self.module.list_merged_pull_requests( + fake_client, + "example-org", + "example-repo", + "contributor", + limit=2, + merged_since="2026-04-01", + ) + + actual_numbers = [pull_request["number"] for pull_request in actual["pull_requests"]] + + self.assertEqual(actual["repository"], "example-org/example-repo") + self.assertEqual(actual["merged_since"], "2026-04-01") + self.assertEqual(actual_numbers, [201, 202]) + self.assertTrue(actual["pull_requests"][0]["same_repository"]) + self.assertFalse(actual["pull_requests"][1]["same_repository"]) + self.assertEqual(actual["pull_requests"][0]["merge_commit_oid"], "merge-sha-201") + self.assertEqual(actual["pull_requests"][1]["merge_commit_oid"], "merge-sha-202") + self.assertEqual(actual["pull_requests"][0]["unresolved_review_thread_count"], 1) + self.assertEqual(actual["pull_requests"][1]["unresolved_review_thread_count"], 2) + self.assertTrue(actual["pull_requests"][1]["review_thread_count_truncated"]) + + def test_list_merged_pull_requests_applies_limit_after_merge_ordering(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "search": { + "nodes": [ + { + "id": "pr_201", + "number": 201, + "title": "Recently updated older merge", + "url": "https://example.com/pr/201", + "headRefName": "older-merge", + "headRefOid": "sha-201", + "baseRefName": "main", + "updatedAt": "2026-04-15T00:00:00Z", + "mergedAt": "2026-04-10T00:00:00Z", + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + } + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "cursor-1", + }, + } + }, + { + "search": { + "nodes": [ + { + "id": "pr_202", + "number": 202, + "title": "Newest merge", + "url": "https://example.com/pr/202", + "headRefName": "newest-merge", + "headRefOid": "sha-202", + "baseRefName": "main", + "updatedAt": "2026-04-09T00:00:00Z", + "mergedAt": "2026-04-12T00:00:00Z", + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + }, + ] + + actual = self.module.list_merged_pull_requests( + fake_client, + "example-org", + "example-repo", + "contributor", + limit=1, + ) + + self.assertEqual([pull_request["number"] for pull_request in actual["pull_requests"]], [202]) + self.assertEqual(fake_client.execute.call_count, 2) + + def test_list_merged_pull_requests_stops_after_requested_limit_window(self): + fake_client = mock.Mock() + fake_client.execute.return_value = { + "search": { + "nodes": [ + { + "id": "pr_201", + "number": 201, + "title": "Older merge", + "url": "https://example.com/pr/201", + "headRefName": "older-merge", + "headRefOid": "sha-201", + "baseRefName": "main", + "updatedAt": "2026-04-09T00:00:00Z", + "mergedAt": "2026-04-10T00:00:00Z", + "mergeCommit": {"oid": "merge-sha-201"}, + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + }, + { + "id": "pr_202", + "number": 202, + "title": "Newest merge", + "url": "https://example.com/pr/202", + "headRefName": "newest-merge", + "headRefOid": "sha-202", + "baseRefName": "main", + "updatedAt": "2026-04-09T00:00:00Z", + "mergedAt": "2026-04-12T00:00:00Z", + "mergeCommit": {"oid": "merge-sha-202"}, + "headRepositoryOwner": {"login": "example-org"}, + "headRepository": {"name": "example-repo"}, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + }, + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + } + + actual = self.module.list_merged_pull_requests( + fake_client, + "example-org", + "example-repo", + "contributor", + limit=1, + ) + + self.assertEqual([pull_request["number"] for pull_request in actual["pull_requests"]], [202]) + self.assertEqual(actual["pull_requests"][0]["merge_commit_oid"], "merge-sha-202") + self.assertEqual(fake_client.execute.call_count, 1) + + def test_list_unresolved_threads_filters_resolved_threads(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "repository": { + "pullRequest": { + "id": "pr_101", + "number": 101, + "title": "Recent", + "url": "https://example.com/pr/101", + "headRefName": "feature-recent", + "headRefOid": "sha-recent", + "reviewThreads": { + "nodes": [ + { + "id": "thread-resolved", + "isResolved": True, + "isOutdated": False, + "isCollapsed": False, + "path": "app.py", + "line": 10, + "diffSide": "RIGHT", + "startLine": None, + "startDiffSide": None, + "originalLine": 10, + "originalStartLine": None, + "subjectType": "LINE", + "comments": { + "totalCount": 1, + "nodes": [], + }, + }, + { + "id": "thread-open-1", + "isResolved": False, + "isOutdated": False, + "isCollapsed": False, + "path": "app.py", + "line": 12, + "diffSide": "RIGHT", + "startLine": None, + "startDiffSide": None, + "originalLine": 12, + "originalStartLine": None, + "subjectType": "LINE", + "comments": { + "totalCount": 1, + "nodes": [ + { + "id": "comment-1", + "body": "Please fix this.", + "createdAt": "2026-04-10T00:00:00Z", + "updatedAt": "2026-04-10T00:00:00Z", + "url": "https://example.com/comment/1", + "author": {"login": "reviewer"}, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + }, + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "cursor-1", + }, + }, + } + } + }, + { + "repository": { + "pullRequest": { + "id": "pr_101", + "number": 101, + "title": "Recent", + "url": "https://example.com/pr/101", + "headRefName": "feature-recent", + "headRefOid": "sha-recent", + "reviewThreads": { + "nodes": [ + { + "id": "thread-open-2", + "isResolved": False, + "isOutdated": True, + "isCollapsed": False, + "path": "other.py", + "line": 30, + "diffSide": "RIGHT", + "startLine": 28, + "startDiffSide": "RIGHT", + "originalLine": 30, + "originalStartLine": 28, + "subjectType": "LINE", + "comments": { + "totalCount": 1, + "nodes": [ + { + "id": "comment-2", + "body": "Outdated comment.", + "createdAt": "2026-04-10T00:00:00Z", + "updatedAt": "2026-04-10T00:00:00Z", + "url": "https://example.com/comment/2", + "author": {"login": "reviewer"}, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + } + } + }, + ] + + actual = self.module.list_unresolved_threads(fake_client, "example-org", "example-repo", 101) + + expected_thread_ids = ["thread-open-1", "thread-open-2"] + actual_thread_ids = [thread["thread_id"] for thread in actual["review_threads"]] + + self.assertEqual(actual["unresolved_thread_count"], 2) + self.assertEqual(actual_thread_ids, expected_thread_ids) + self.assertTrue(actual["review_threads"][1]["is_outdated"]) + + def test_list_unresolved_threads_fetches_remaining_thread_comments(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "repository": { + "pullRequest": { + "id": "pr_101", + "number": 101, + "title": "Recent", + "url": "https://example.com/pr/101", + "headRefName": "feature-recent", + "headRefOid": "sha-recent", + "reviewThreads": { + "nodes": [ + { + "id": "thread-open-1", + "isResolved": False, + "isOutdated": False, + "isCollapsed": False, + "path": "app.py", + "line": 12, + "diffSide": "RIGHT", + "startLine": None, + "startDiffSide": None, + "originalLine": 12, + "originalStartLine": None, + "subjectType": "LINE", + "comments": { + "totalCount": 2, + "nodes": [ + { + "id": "comment-1", + "body": "First comment.", + "createdAt": "2026-04-10T00:00:00Z", + "updatedAt": "2026-04-10T00:00:00Z", + "url": "https://example.com/comment/1", + "author": {"login": "reviewer"}, + } + ], + "pageInfo": { + "hasNextPage": True, + "endCursor": "comment-page-1", + }, + }, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + }, + } + } + }, + { + "node": { + "comments": { + "totalCount": 2, + "nodes": [ + { + "id": "comment-2", + "body": "Second comment.", + "createdAt": "2026-04-11T00:00:00Z", + "updatedAt": "2026-04-11T00:00:00Z", + "url": "https://example.com/comment/2", + "author": {"login": "maintainer"}, + } + ], + "pageInfo": { + "hasNextPage": False, + "endCursor": None, + }, + } + } + }, + ] + + actual = self.module.list_unresolved_threads(fake_client, "example-org", "example-repo", 101) + + self.assertEqual(actual["review_threads"][0]["comment_count"], 2) + self.assertEqual( + [comment["id"] for comment in actual["review_threads"][0]["comments"]], + ["comment-1", "comment-2"], + ) + + def test_reply_to_thread_builds_dry_run_payload(self): + fake_client = mock.Mock() + + actual = self.module.reply_to_thread( + fake_client, + "thread-123", + "Applied a targeted fix.", + resolve=True, + dry_run=True, + ) + + expected_variables = { + "input": { + "pullRequestReviewThreadId": "thread-123", + "body": "Applied a targeted fix.", + } + } + + self.assertEqual(actual["variables"], expected_variables) + self.assertEqual(actual["resolve_variables"], {"input": {"threadId": "thread-123"}}) + fake_client.execute.assert_not_called() + + def test_reply_to_thread_posts_reply_and_resolves(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "addPullRequestReviewThreadReply": { + "comment": { + "id": "comment-123", + "url": "https://example.com/comment/123", + "body": "Applied a targeted fix.", + } + } + }, + { + "resolveReviewThread": { + "thread": { + "id": "thread-123", + "isResolved": True, + } + } + }, + ] + + actual = self.module.reply_to_thread( + fake_client, + "thread-123", + "Applied a targeted fix.", + resolve=True, + dry_run=False, + ) + + self.assertEqual(actual["reply"]["id"], "comment-123") + self.assertTrue(actual["thread"]["is_resolved"]) + self.assertEqual(fake_client.execute.call_count, 2) + + def test_reply_to_thread_returns_partial_success_when_resolve_fails(self): + fake_client = mock.Mock() + fake_client.execute.side_effect = [ + { + "addPullRequestReviewThreadReply": { + "comment": { + "id": "comment-123", + "url": "https://example.com/comment/123", + "body": "Applied a targeted fix.", + } + } + }, + RuntimeError("Resolve failed"), + ] + + actual = self.module.reply_to_thread( + fake_client, + "thread-123", + "Applied a targeted fix.", + resolve=True, + dry_run=False, + ) + + self.assertEqual(actual["reply"]["id"], "comment-123") + self.assertFalse(actual["thread"]["is_resolved"]) + self.assertEqual(actual["resolve_error"], "Resolve failed") diff --git a/tests/test_preview.py b/tests/test_preview.py new file mode 100644 index 0000000..c60d915 --- /dev/null +++ b/tests/test_preview.py @@ -0,0 +1,1284 @@ +import base64 +import hashlib +import os +import re +import io +import json +import subprocess +import sys +import tempfile +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock +from urllib.parse import parse_qs, urlsplit + +from unittest import TestCase + +from agentics_tools.tools import context, preview, preview_sync + +REPO = "example-org/example-repo" +BRANCH = "codex/example-branch" +BRANCH_DIRECTORY = f"codex-example-branch-{hashlib.sha256(BRANCH.encode()).hexdigest()[:16]}" +PREVIEW_DIRECTORY = f".github/pr-previews/.local/{BRANCH_DIRECTORY}" +BEFORE_PATH = f"{PREVIEW_DIRECTORY}/project-dashboard-before.png" +AFTER_PATH = f"{PREVIEW_DIRECTORY}/project-dashboard-after.png" +BEFORE_URL = "https://github.com/user-attachments/assets/11111111-1111-4111-8111-111111111111" +AFTER_URL = "https://github.com/user-attachments/assets/22222222-2222-4222-8222-222222222222" +PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aP1sAAAAASUVORK5CYII=") + + +class RepositoryContextTests(TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name).resolve() + self.nested = self.root / "nested" + self.nested.mkdir() + subprocess.run(["git", "init", "--quiet"], cwd=self.root, check=True, capture_output=True) + context.set_repo_root(None) + self.addCleanup(context.set_repo_root, None) + + def test_discovers_callers_repository_from_nested_working_directory(self): + with mock.patch.object(Path, "cwd", return_value=self.nested): + self.assertEqual(context.repo_root(), self.root) + self.assertFalse(context.has_explicit_repo_root()) + self.assertEqual(preview.branch_preview_dir(BRANCH).relative_to(self.root).as_posix(), PREVIEW_DIRECTORY) + + def test_explicit_target_is_canonical_and_independent_of_current_directory(self): + context.set_repo_root(self.nested) + with mock.patch.object(Path, "cwd", return_value=self.root / "does-not-exist"): + self.assertTrue(context.has_explicit_repo_root()) + self.assertEqual(context.repo_root(), self.root) + self.assertEqual(preview.repo_root(), self.root) + self.assertEqual(preview_sync.repo_root(), self.root) + self.assertEqual(preview.run_git(["rev-parse", "--show-toplevel"]).stdout.strip(), str(self.root)) + self.assertEqual(preview_sync.run(["git", "rev-parse", "--show-toplevel"]), str(self.root)) + + def test_reset_returns_to_current_directory_discovery(self): + context.set_repo_root(self.root) + context.set_repo_root(None) + with mock.patch.object(Path, "cwd", return_value=self.nested): + self.assertFalse(context.has_explicit_repo_root()) + self.assertEqual(context.repo_root(), self.root) + + def test_selected_repository_removes_ambient_gh_repo_override(self): + context.set_repo_root(self.root) + with ( + mock.patch.dict(os.environ, {"GH_REPO": "ambient/wrong-repository", "PRESERVE_THIS": "yes"}), + mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "{}", "")) as run, + ): + preview_sync.run(["gh", "repo", "view"]) + self.assertNotIn("GH_REPO", run.call_args.kwargs["env"]) + self.assertEqual(run.call_args.kwargs["env"]["PRESERVE_THIS"], "yes") + self.assertEqual(os.environ["GH_REPO"], "ambient/wrong-repository") + + def test_without_selected_repository_gh_environment_is_inherited(self): + with ( + mock.patch.object(preview_sync, "repo_root", return_value=self.root), + mock.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess([], 0, "{}", "")) as run, + ): + preview_sync.run(["gh", "repo", "view"]) + self.assertIsNone(run.call_args.kwargs["env"]) + + def test_invalid_target_and_non_repository_have_clear_errors(self): + with self.assertRaisesRegex(ValueError, "directory does not exist"): + context.set_repo_root(self.root / "missing") + outside = tempfile.TemporaryDirectory() + self.addCleanup(outside.cleanup) + with self.assertRaisesRegex(RuntimeError, "No Git repository found"): + context.set_repo_root(outside.name) + with mock.patch.object(Path, "cwd", return_value=Path(outside.name)): + with self.assertRaisesRegex(RuntimeError, "Use --repo-root"): + context.repo_root() + + +class PreviewScriptTests(TestCase): + def setUp(self): + self.pr_preview = preview + self.sync_pr_preview_comment = preview_sync + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.temp_repo_root = Path(directory.name).resolve() + subprocess.run(["git", "init", "--quiet"], cwd=self.temp_repo_root, check=True, capture_output=True) + context.set_repo_root(self.temp_repo_root) + self.addCleanup(context.set_repo_root, None) + + def create_preview(self, relative_path=BEFORE_PATH, content=PNG): + path = self.temp_repo_root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + def test_current_branch_name_raises_friendly_error_for_detached_head(self): + with mock.patch.object( + self.pr_preview.subprocess, + "run", + return_value=subprocess.CompletedProcess( + args=["git", "symbolic-ref", "--quiet", "--short", "HEAD"], + returncode=1, + stdout="", + stderr="detached HEAD", + ), + ): + with self.assertRaisesRegex(RuntimeError, re.escape("Pass --branch explicitly.")): + self.pr_preview.current_branch_name() + + def test_sanitize_branch_name_rejects_dot_segments(self): + for branch_name in (".", "..", "-.-"): + with self.subTest(branch_name=branch_name): + with self.assertRaisesRegex(ValueError, re.escape("at least one alphanumeric character")): + self.pr_preview.sanitize_branch_name(branch_name) + + def test_branch_directories_are_distinct_after_slug_truncation_and_case_folding(self): + branch_groups = ( + ("codex/foo/bar", "codex/foo-bar"), + ("codex/" + "a" * 90 + "first", "codex/" + "a" * 90 + "second"), + ("codex/Feature", "codex/feature"), + ) + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + for first_branch, second_branch in branch_groups: + with self.subTest(first_branch=first_branch, second_branch=second_branch): + first_dir = self.pr_preview.branch_preview_dir(first_branch) + second_dir = self.pr_preview.branch_preview_dir(second_branch) + self.assertNotEqual(str(first_dir).casefold(), str(second_dir).casefold()) + self.assertLessEqual(len(first_dir.name), 80) + first = self.pr_preview.preview_output_path("dashboard", first_branch) + first.parent.mkdir(parents=True) + first.write_bytes(PNG) + self.assertEqual(self.pr_preview.list_preview_files(second_branch), []) + self.pr_preview.clean_preview_dir(second_branch) + self.assertTrue(first.is_file()) + + def test_versioned_branch_directory_cannot_reuse_or_delete_legacy_cache(self): + branch = "codex/foo" + legacy_branch = self.pr_preview.sanitize_branch_name(branch) + legacy_path = self.create_preview(f".github/pr-previews/{legacy_branch}/dashboard.png") + (legacy_path.parent / "attachments.json").write_text('{"legacy": "cache"}') + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + self.assertNotEqual(self.pr_preview.branch_preview_dir(branch), legacy_path.parent) + self.assertEqual(self.pr_preview.list_preview_files(branch), []) + self.pr_preview.clean_preview_dir(branch) + self.assertTrue(legacy_path.is_file()) + self.assertTrue((legacy_path.parent / "attachments.json").is_file()) + + def test_id_command_normalizes_mixed_case_preview_ids(self): + stdout = io.StringIO() + + with mock.patch.object(sys, "argv", ["pr_preview.py", "id", "Project-App-Login"]): + with redirect_stdout(stdout): + exit_code = self.pr_preview.main() + + self.assertEqual(exit_code, 0) + self.assertEqual(stdout.getvalue().strip(), "project-app-login") + + def test_path_command_adds_full_page_variant_suffix_after_state(self): + stdout = io.StringIO() + + with mock.patch.object( + sys, + "argv", + [ + "pr_preview.py", + "path", + "Project-Dashboard", + "--branch", + "codex/example-branch", + "--state", + "after", + "--variant", + "full-page", + ], + ): + with redirect_stdout(stdout): + exit_code = self.pr_preview.main() + + self.assertEqual(exit_code, 0) + self.assertEqual( + stdout.getvalue().strip(), + f"{PREVIEW_DIRECTORY}/project-dashboard-after-full-page.png", + ) + + def test_preview_output_path_rejects_symlink_escapes_before_capture(self): + outside_directory = tempfile.TemporaryDirectory() + self.addCleanup(outside_directory.cleanup) + preview_root = self.temp_repo_root / self.pr_preview.PREVIEW_ROOT + preview_root.mkdir(parents=True) + (preview_root / self.pr_preview.sanitize_branch_name("codex/escape-branch")).symlink_to( + outside_directory.name, target_is_directory=True + ) + safe_directory = preview_root / BRANCH_DIRECTORY + safe_directory.mkdir() + (safe_directory / "dashboard-after.png").symlink_to(Path(outside_directory.name) / "target.png") + + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + with self.assertRaises(ValueError): + self.pr_preview.branch_preview_dir("codex/escape-branch") + with self.assertRaises(ValueError): + self.pr_preview.preview_output_path("dashboard", "codex/escape-branch", "after") + with self.assertRaises(ValueError): + self.pr_preview.preview_output_path("dashboard", "codex/example-branch", "after") + + def test_preview_output_path_rejects_internal_branch_directory_symlink(self): + original = PNG + b"existing asset" + target = self.create_preview("protected-assets/dashboard-after.png", original) + preview_root = self.temp_repo_root / self.pr_preview.PREVIEW_ROOT + preview_root.mkdir(parents=True) + (preview_root / self.pr_preview.sanitize_branch_name("codex/internal-branch")).symlink_to( + target.parent, target_is_directory=True + ) + + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + with self.assertRaisesRegex(ValueError, re.escape("directories must not use symbolic links")): + self.pr_preview.preview_output_path("dashboard", "codex/internal-branch", "after") + + self.assertEqual(target.read_bytes(), original) + + def test_preview_output_path_rejects_internal_existing_output_file_symlink(self): + original = PNG + b"existing asset" + target = self.create_preview("protected-assets/image.png", original) + preview_directory = self.temp_repo_root / PREVIEW_DIRECTORY + preview_directory.mkdir(parents=True) + (preview_directory / "dashboard-after.png").symlink_to(target) + + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + with self.assertRaisesRegex(ValueError, re.escape("output files must not use symbolic links")): + self.pr_preview.preview_output_path("dashboard", "codex/example-branch", "after") + + self.assertEqual(target.read_bytes(), original) + + + def test_require_attachment_support_rejects_old_cli(self): + with mock.patch.object(self.sync_pr_preview_comment, "run", return_value="gh version 2.96.0 (2026-06-01)"): + with self.assertRaisesRegex(RuntimeError, re.escape("2.99.0")): + self.sync_pr_preview_comment.require_attachment_support() + + def test_require_attachment_support_accepts_minimum_and_newer_cli(self): + for version in ("2.99.0", "2.100.0", "3.0.0"): + with self.subTest(version=version): + with mock.patch.object(self.sync_pr_preview_comment, "run", return_value=f"gh version {version}"): + self.sync_pr_preview_comment.require_attachment_support() + + def test_gh_json_binds_api_requests_to_github_com(self): + for endpoint in ("user", f"repos/{REPO}", "https://uploads.github.com/user-attachments/assets"): + with self.subTest(endpoint=endpoint): + with mock.patch.object(self.sync_pr_preview_comment, "run", return_value='{"id": 123}') as run: + self.assertEqual(self.sync_pr_preview_comment.gh_json(["api", endpoint]), {"id": 123}) + + args = run.call_args.args[0] + self.assertEqual(args[:2], ["gh", "api"]) + self.assertIn(endpoint, args) + self.assertEqual(args[args.index("--hostname") + 1], "github.com") + + def test_infer_pr_number_binds_github_repository_and_branch(self): + for branch in (None, "codex/requested-branch"): + with self.subTest(branch=branch): + with ( + mock.patch.object(self.sync_pr_preview_comment, "run", return_value="2517") as run, + mock.patch.object( + self.sync_pr_preview_comment, "current_branch_name", return_value="codex/current-branch" + ) as current_branch, + ): + self.assertEqual(self.sync_pr_preview_comment.infer_pr_number(REPO, branch), 2517) + + args = run.call_args.args[0] + self.assertEqual(args[:4], ["gh", "pr", "view", branch or "codex/current-branch"]) + self.assertEqual(args[args.index("--repo") + 1], f"github.com/{REPO}") + if branch is None: + current_branch.assert_called_once_with() + else: + current_branch.assert_not_called() + + def test_validate_preview_files_rejects_unsafe_or_invalid_files(self): + valid = self.create_preview() + empty = self.create_preview(f"{PREVIEW_DIRECTORY}/empty.png", b"") + invalid = self.create_preview(f"{PREVIEW_DIRECTORY}/invalid.png", b"private configuration") + oversized = self.create_preview(f"{PREVIEW_DIRECTORY}/oversized.png") + with oversized.open("r+b") as handle: + handle.truncate(10 * 1024 * 1024 + 1) + directory = self.temp_repo_root / PREVIEW_DIRECTORY / "directory.png" + directory.mkdir() + symlink = self.temp_repo_root / PREVIEW_DIRECTORY / "symlink.png" + symlink.symlink_to(valid) + missing = self.temp_repo_root / PREVIEW_DIRECTORY / "missing.png" + outside_directory = tempfile.TemporaryDirectory() + self.addCleanup(outside_directory.cleanup) + (Path(outside_directory.name) / "outside.png").write_bytes(PNG) + linked_directory = self.temp_repo_root / ".github/pr-previews/outside-branch" + linked_directory.symlink_to(outside_directory.name, target_is_directory=True) + escaped = linked_directory / "outside.png" + + with mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root): + self.sync_pr_preview_comment.validate_preview_files([valid]) + for path in (missing, directory, symlink, empty, invalid, oversized, escaped): + with self.subTest(filename=path.name): + with self.assertRaises((OSError, RuntimeError, ValueError)): + self.sync_pr_preview_comment.validate_preview_files([path]) + + def test_upload_validates_all_files_before_any_network_write(self): + valid = self.create_preview() + empty = self.create_preview(AFTER_PATH, b"") + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "gh_json") as gh_json, + ): + with self.assertRaises((RuntimeError, ValueError)): + self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [valid, empty]) + + gh_json.assert_not_called() + + def test_upload_uses_native_attachment_endpoint_and_binary_input(self): + preview = self.create_preview() + + def inspect_snapshot(args): + snapshot = Path(args[args.index("--input") + 1]) + self.assertNotEqual(snapshot, preview) + self.assertEqual(snapshot.parent, preview.parent) + self.assertEqual(snapshot.read_bytes(), PNG) + return {"url": BEFORE_URL} + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=inspect_snapshot) as gh_json, + ): + attachments, uploaded_digests = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + + self.assertEqual(attachments, {BEFORE_PATH: BEFORE_URL}) + self.assertEqual(uploaded_digests, {BEFORE_PATH: self.pr_preview.preview_file_digest(preview)}) + args = gh_json.call_args.args[0] + upload_url = urlsplit(args[1]) + self.assertEqual(args[0], "api") + self.assertEqual(upload_url.scheme, "https") + self.assertEqual(upload_url.netloc, "uploads.github.com") + self.assertEqual(upload_url.path, "/user-attachments/assets") + self.assertEqual( + parse_qs(upload_url.query), + {"name": [preview.name], "content_type": ["image/png"], "repository_id": ["123"]}, + ) + self.assertEqual(args[args.index("--method") + 1], "POST") + self.assertIn("Content-Type: application/octet-stream", args) + + def test_upload_reuses_unchanged_content_and_uploads_changed_content(self): + preview = self.create_preview() + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", side_effect=[{"url": BEFORE_URL}, {"url": AFTER_URL}] + ) as gh_json, + ): + first, first_digests = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + second, second_digests = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + preview.write_bytes(PNG + b"changed") + changed, changed_digests = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + + self.assertEqual(first, {BEFORE_PATH: BEFORE_URL}) + self.assertEqual(second, first) + self.assertEqual(changed, {BEFORE_PATH: AFTER_URL}) + self.assertEqual(first_digests, second_digests) + self.assertNotEqual(changed_digests, first_digests) + self.assertEqual(gh_json.call_count, 2) + + def test_upload_does_not_reuse_another_repository_attachment(self): + preview = self.create_preview() + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", side_effect=[{"url": BEFORE_URL}, {"url": AFTER_URL}] + ) as gh_json, + ): + self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + attachments, _ = self.sync_pr_preview_comment.upload_preview_files("other/repository", 456, [preview]) + + self.assertEqual(attachments, {BEFORE_PATH: AFTER_URL}) + self.assertEqual(gh_json.call_count, 2) + + def test_upload_does_not_reuse_cache_for_recreated_repository_with_same_name(self): + preview = self.create_preview() + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", side_effect=[{"url": BEFORE_URL}, {"url": AFTER_URL}] + ) as api, + ): + self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + attachments, _ = self.sync_pr_preview_comment.upload_preview_files(REPO, 456, [preview]) + self.assertEqual(attachments, {BEFORE_PATH: AFTER_URL}) + self.assertEqual(api.call_count, 2) + cache = json.loads((preview.parent / "attachments.json").read_text()) + self.assertEqual(cache["repository_id"], 456) + + def test_upload_persists_completed_assets_for_partial_retry(self): + before = self.create_preview() + after = self.create_preview(AFTER_PATH) + cache = before.parent / "attachments.json" + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object( + self.sync_pr_preview_comment, + "gh_json", + side_effect=[{"url": BEFORE_URL}, RuntimeError("upload interrupted")], + ) as first_attempt, + ): + with self.assertRaisesRegex(RuntimeError, re.escape("upload interrupted")): + self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [before, after]) + + self.assertEqual(first_attempt.call_count, 2) + self.assertIn(BEFORE_URL, cache.read_text()) + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", return_value={"url": AFTER_URL}) as retry, + ): + attachments, _ = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [before, after]) + + self.assertEqual(attachments, {BEFORE_PATH: BEFORE_URL, AFTER_PATH: AFTER_URL}) + retry.assert_called_once() + self.assertIn(AFTER_URL, cache.read_text()) + + def test_upload_rejects_unexpected_asset_urls(self): + preview = self.create_preview() + for url in ( + "https://example.com/user-attachments/assets/123", + "http://github.com/user-attachments/assets/123", + "https://github.com/example-org/example-repo/blob/main/secret.png", + ): + with self.subTest(url=url): + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", return_value={"url": url}), + ): + with self.assertRaises((RuntimeError, ValueError)): + self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + + def test_upload_recovers_from_malformed_cache_or_invalid_cached_url(self): + preview = self.create_preview() + invalid_url_cache = { + "repo": REPO, + "repository_id": 123, + "files": {BEFORE_PATH: {"sha256": self.pr_preview.preview_file_digest(preview), "url": None}}, + } + for cache_contents in ("{truncated", json.dumps(invalid_url_cache)): + with self.subTest(cache_contents=cache_contents): + (preview.parent / "attachments.json").write_text(cache_contents) + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", return_value={"url": BEFORE_URL} + ) as gh_json, + ): + attachments, _ = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + + self.assertEqual(attachments, {BEFORE_PATH: BEFORE_URL}) + gh_json.assert_called_once() + + def test_upload_rejects_attachment_metadata_symlink_without_changing_target(self): + preview = self.create_preview() + protected = self.temp_repo_root / "protected.json" + original = b'{"protected": true}\n' + protected.write_bytes(original) + (preview.parent / "attachments.json").symlink_to(protected) + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "gh_json") as gh_json, + ): + with self.assertRaises(ValueError): + self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + + self.assertEqual(protected.read_bytes(), original) + gh_json.assert_not_called() + + def test_upload_ignores_obsolete_temporary_metadata_symlink(self): + preview = self.create_preview() + protected = self.temp_repo_root / "protected.json" + original = b'{"protected": true}\n' + protected.write_bytes(original) + (preview.parent / "attachments.tmp").symlink_to(protected) + + with ( + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", return_value={"url": BEFORE_URL}), + ): + attachments, _ = self.sync_pr_preview_comment.upload_preview_files(REPO, 123, [preview]) + + self.assertEqual(attachments, {BEFORE_PATH: BEFORE_URL}) + self.assertEqual(protected.read_bytes(), original) + self.assertIn(BEFORE_URL, (preview.parent / "attachments.json").read_text()) + + def test_fetch_existing_marker_comment_reads_all_pages_before_returning_owned_match(self): + gh_json = mock.Mock( + side_effect=[ + [{"id": 1, "body": "Unrelated comment", "user": {"login": "someone-else"}}], + [{"id": 2, "body": f"{self.pr_preview.MARKER_COMMENT}\nhello", "user": {"login": "preview-owner"}}], + [], + ] + ) + + with mock.patch.object(self.sync_pr_preview_comment, "gh_json", gh_json): + actual = self.sync_pr_preview_comment.fetch_existing_marker_comment(REPO, 1116, "preview-owner") + + self.assertEqual(actual["id"], 2) + self.assertEqual( + gh_json.mock_calls, + [ + mock.call( + [ + "api", + "repos/example-org/example-repo/issues/1116/comments?per_page=100&page=1", + ] + ), + mock.call( + [ + "api", + "repos/example-org/example-repo/issues/1116/comments?per_page=100&page=2", + ] + ), + mock.call( + [ + "api", + "repos/example-org/example-repo/issues/1116/comments?per_page=100&page=3", + ] + ), + ], + ) + + def test_fetch_existing_marker_comment_leaves_other_authors_untouched(self): + gh_json = mock.Mock( + side_effect=[ + [{"id": 1, "body": self.pr_preview.MARKER_COMMENT, "user": {"login": "someone-else"}}], + [], + ] + ) + + with mock.patch.object(self.sync_pr_preview_comment, "gh_json", gh_json): + with self.assertRaisesRegex(RuntimeError, re.escape("owned by")): + self.sync_pr_preview_comment.fetch_existing_marker_comment(REPO, 1116, "preview-owner") + + def test_fetch_existing_marker_comment_selects_oldest_owned_marker_across_pages(self): + gh_json = mock.Mock( + side_effect=[ + [{"id": 1, "body": self.pr_preview.MARKER_COMMENT, "user": {"login": "preview-owner"}}], + [{"id": 2, "body": self.pr_preview.MARKER_COMMENT, "user": {"login": "preview-owner"}}], + [], + ] + ) + + with mock.patch.object(self.sync_pr_preview_comment, "gh_json", gh_json): + existing = self.sync_pr_preview_comment.fetch_existing_marker_comment(REPO, 1116, "preview-owner") + self.assertEqual(existing["id"], 1) + + def test_marker_mentions_are_not_owned_automation_comments(self): + marker = self.pr_preview.MARKER_COMMENT + comments = [ + {"id": 1, "body": f"Please preserve {marker}", "user": {"login": "preview-owner"}}, + {"id": 2, "body": f"Quoted marker:\n{marker}", "user": {"login": "preview-owner"}}, + ] + with mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=[comments, []]): + self.assertIsNone(self.sync_pr_preview_comment.fetch_existing_marker_comment(REPO, 2517, "preview-owner")) + + def test_build_comment_body_embeds_native_attachments_and_head_sha(self): + full_page_path = f"{PREVIEW_DIRECTORY}/project-dashboard-after-full-page.png" + body = self.sync_pr_preview_comment.build_comment_body( + repo=REPO, + head_sha="abc123", + branch_name="codex/example-branch", + attachments={BEFORE_PATH: BEFORE_URL, full_page_path: AFTER_URL}, + ) + + self.assertIn(self.pr_preview.MARKER_COMMENT, body) + self.assertIn("Frontend previews for ` codex/example-branch `.", body) + self.assertNotIn(BRANCH_DIRECTORY, body) + self.assertIn("abc123", body) + self.assertIn("before targeted", body) + self.assertIn("after full page", body) + self.assertIn(f"]({BEFORE_URL})", body) + self.assertIn(f"]({AFTER_URL})", body) + self.assertEqual(body.count("!["), 2) + self.assertNotIn("/blob/", body) + self.assertNotIn("raw.githubusercontent.com", body) + self.assertNotIn("Remove `.github/pr-previews", body) + self.assertIn("Capture revision unavailable.", body) + + def test_comment_branch_label_uses_code_delimiter_longer_than_embedded_backticks(self): + branch = "codex/__`label``" + body = self.sync_pr_preview_comment.build_comment_body( + repo=REPO, head_sha="abc123", branch_name=branch, attachments={} + ) + self.assertIn(f"Frontend previews for ``` {branch} ```.", body) + self.assertNotIn(self.pr_preview.sanitize_branch_name(branch), body) + + def initialize_source_repo(self): + source = self.temp_repo_root / "source.txt" + source.write_text("original source\n") + (self.temp_repo_root / ".gitignore").write_text(".github/pr-previews/\n") + for args in ( + ["init", "--quiet"], + ["config", "user.name", "Preview tests"], + ["config", "user.email", "preview@example.com"], + ["config", "commit.gpgsign", "false"], + ["config", "core.autocrlf", "false"], + ["add", "."], + ["commit", "--quiet", "-m", "Initial source"], + ): + subprocess.run(["git", *args], cwd=self.temp_repo_root, check=True, capture_output=True) + return source + + def test_capture_metadata_preserves_stable_clean_and_dirty_sources_and_ignores_modified_images(self): + source = self.initialize_source_repo() + before = self.create_preview() + after = self.create_preview(AFTER_PATH) + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + clean = self.pr_preview.capture_source_snapshot() + self.pr_preview.record_preview_capture([before], clean) + source.write_text("changed source\n") + dirty = self.pr_preview.capture_source_snapshot() + self.pr_preview.record_preview_capture([after], dirty) + captures = self.pr_preview.load_capture_metadata([before, after]) + self.assertEqual(captures[BEFORE_PATH]["head_sha"], clean["head_sha"]) + self.assertFalse(captures[BEFORE_PATH]["dirty"]) + self.assertTrue(captures[AFTER_PATH]["dirty"]) + self.assertEqual(captures[AFTER_PATH]["source_fingerprint"], dirty["source_fingerprint"]) + before.write_bytes(PNG + b"modified") + remaining = self.pr_preview.load_capture_metadata([before, after]) + self.assertEqual(set(remaining), {AFTER_PATH}) + + def test_source_snapshot_detects_edits_to_already_dirty_and_nonignored_untracked_files(self): + source = self.initialize_source_repo() + source.write_text("first edit\n") + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + first = self.pr_preview.capture_source_snapshot() + source.write_text("second edit\n") + second = self.pr_preview.capture_source_snapshot() + untracked = self.temp_repo_root / "new-template.html" + untracked.write_text("first untracked version") + third = self.pr_preview.capture_source_snapshot() + untracked.write_text("second untracked version") + fourth = self.pr_preview.capture_source_snapshot() + self.assertEqual(first["head_sha"], second["head_sha"]) + self.assertTrue(first["dirty"] and second["dirty"]) + self.assertEqual(len({item["source_fingerprint"] for item in (first, second, third, fourth)}), 4) + + def test_source_snapshot_includes_staged_content_when_worktree_matches_head(self): + source = self.initialize_source_repo() + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + source.write_text("first staged edit\n") + subprocess.run(["git", "add", "source.txt"], cwd=self.temp_repo_root, check=True, capture_output=True) + source.write_text("original source\n") + first = self.pr_preview.capture_source_snapshot() + source.write_text("second staged edit\n") + subprocess.run(["git", "add", "source.txt"], cwd=self.temp_repo_root, check=True, capture_output=True) + source.write_text("original source\n") + second = self.pr_preview.capture_source_snapshot() + self.assertTrue(first["dirty"] and second["dirty"]) + self.assertNotEqual(first["source_fingerprint"], second["source_fingerprint"]) + + def test_source_snapshot_ignores_local_preview_artifacts(self): + self.initialize_source_repo() + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + before = self.pr_preview.capture_source_snapshot() + preview = self.create_preview() + (preview.parent / "attachments.json").write_text('{"upload": "in progress"}') + self.assertEqual(self.pr_preview.capture_source_snapshot(), before) + + def test_capture_drift_rejects_provenance_without_replacing_existing_metadata(self): + source = self.initialize_source_repo() + preview = self.create_preview() + metadata_path = preview.parent / "captures.json" + metadata_path.write_text('{"existing": "capture"}') + original_metadata = metadata_path.read_bytes() + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + snapshot = self.pr_preview.capture_source_snapshot() + source.write_text("changed during rendering\n") + with self.assertRaisesRegex(RuntimeError, re.escape("Source changed during capture")): + self.pr_preview.record_preview_capture([preview], snapshot) + self.assertEqual(metadata_path.read_bytes(), original_metadata) + + def test_head_drift_during_capture_cannot_write_provenance(self): + source = self.initialize_source_repo() + preview = self.create_preview() + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + snapshot = self.pr_preview.capture_source_snapshot() + source.write_text("new revision\n") + subprocess.run(["git", "add", "source.txt"], cwd=self.temp_repo_root, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "--quiet", "-m", "Advance source"], + cwd=self.temp_repo_root, + check=True, + capture_output=True, + ) + with self.assertRaisesRegex(RuntimeError, re.escape("Source changed during capture")): + self.pr_preview.record_preview_capture([preview], snapshot) + self.assertFalse((preview.parent / "captures.json").exists()) + + def test_source_drift_while_hashing_previews_cannot_write_provenance(self): + source = self.initialize_source_repo() + preview = self.create_preview() + original_digest = self.pr_preview.preview_file_digest(preview) + + def change_source(path): + source.write_text("changed while hashing\n") + return original_digest + + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + snapshot = self.pr_preview.capture_source_snapshot() + with mock.patch.object(self.pr_preview, "preview_file_digest", side_effect=change_source): + with self.assertRaisesRegex(RuntimeError, re.escape("Source changed while hashing")): + self.pr_preview.record_preview_capture([preview], snapshot) + self.assertFalse((preview.parent / "captures.json").exists()) + + + def test_capture_metadata_symlink_cannot_change_protected_target(self): + self.initialize_source_repo() + preview = self.create_preview() + protected = self.temp_repo_root / "protected.json" + original = b'{"protected": true}\n' + protected.write_bytes(original) + (preview.parent / "captures.json").symlink_to(protected) + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + snapshot = self.pr_preview.capture_source_snapshot() + with self.assertRaises(ValueError): + self.pr_preview.record_preview_capture([preview], snapshot) + with self.assertRaises(ValueError): + self.pr_preview.load_capture_metadata([preview]) + self.assertEqual(protected.read_bytes(), original) + + def test_capture_metadata_falls_back_when_json_or_records_are_malformed(self): + preview = self.create_preview() + digest = self.pr_preview.preview_file_digest(preview) + malformed = ( + "{truncated", + json.dumps(["wrong top-level shape"]), + json.dumps({BEFORE_PATH: "wrong record shape"}), + json.dumps({BEFORE_PATH: {"sha256": digest}}), + json.dumps({BEFORE_PATH: {"sha256": digest, "head_sha": 123, "dirty": False}}), + json.dumps({BEFORE_PATH: {"sha256": digest, "head_sha": "head123", "dirty": "false"}}), + json.dumps({BEFORE_PATH: {"sha256": digest, "head_sha": "head123", "dirty": False}}), + ) + with mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root): + for contents in malformed: + with self.subTest(contents=contents): + (preview.parent / "captures.json").write_text(contents) + self.assertEqual(self.pr_preview.load_capture_metadata([preview]), {}) + + def test_build_comment_body_reports_capture_revision_separately_from_pr_head(self): + body = self.sync_pr_preview_comment.build_comment_body( + repo=REPO, + head_sha="prhead123", + branch_name="codex/example-branch", + attachments={BEFORE_PATH: BEFORE_URL}, + captures={BEFORE_PATH: {"head_sha": "captured456", "dirty": True}}, + ) + + self.assertIn(f"https://github.com/{REPO}/commit/prhead123", body) + self.assertIn(f"https://github.com/{REPO}/commit/captured456", body) + self.assertIn("with uncommitted changes", body) + self.assertNotIn("Capture revision unavailable.", body) + + def marker_comment(self, comment_id, login="preview-owner"): + return { + "id": comment_id, + "body": self.pr_preview.MARKER_COMMENT, + "user": {"login": login}, + "html_url": f"https://github.com/comment/{comment_id}", + } + + def test_upsert_comment_updates_owned_marker_id_after_head_check(self): + comment = self.marker_comment(42) + calls = mock.Mock() + calls.attach_mock(mock.Mock(return_value=comment), "lookup") + calls.attach_mock(mock.Mock(), "validate") + calls.attach_mock(mock.Mock(return_value=comment), "write") + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", calls.lookup), + mock.patch.object(self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[comment]), + mock.patch.object(self.sync_pr_preview_comment, "validate_current_head", calls.validate), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", calls.write), + redirect_stdout(io.StringIO()), + ): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + self.assertEqual([call[0] for call in calls.mock_calls], ["lookup", "write", "validate", "write"]) + args = calls.write.call_args.args[0] + self.assertEqual(args[1], f"repos/{REPO}/issues/comments/42") + self.assertEqual(args[args.index("--method") + 1], "PATCH") + self.assertEqual(json.loads(calls.write.call_args.kwargs["input_text"]), {"body": "preview body"}) + + def test_upsert_concurrent_create_keeps_oldest_owned_marker_and_removes_duplicate(self): + canonical = self.marker_comment(10) + created = self.marker_comment(42) + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=None), + mock.patch.object(self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[canonical, created]), + mock.patch.object(self.sync_pr_preview_comment, "validate_current_head") as validate, + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", side_effect=[created, canonical, canonical, created, None] + ) as gh_json, + redirect_stdout(io.StringIO()), + ): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + requests = [call.args[0] for call in gh_json.call_args_list] + self.assertEqual(requests[0][requests[0].index("--method") + 1], "POST") + self.assertEqual(requests[1][1], f"repos/{REPO}/issues/comments/10") + self.assertEqual(requests[1], ["api", f"repos/{REPO}/issues/comments/10"]) + self.assertEqual(requests[2][requests[2].index("--method") + 1], "PATCH") + self.assertEqual(requests[3], ["api", f"repos/{REPO}/issues/comments/42"]) + self.assertEqual(requests[4], ["api", f"repos/{REPO}/issues/comments/42", "--method", "DELETE"]) + self.assertEqual(validate.call_count, 3) + + def test_upsert_existing_owned_duplicates_converge(self): + canonical = self.marker_comment(10) + duplicate = self.marker_comment(42) + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=canonical), + mock.patch.object( + self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[canonical, duplicate] + ), + mock.patch.object(self.sync_pr_preview_comment, "validate_current_head"), + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", side_effect=[canonical, canonical, duplicate, None] + ) as api, + redirect_stdout(io.StringIO()), + ): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + self.assertEqual(api.call_args.args[0], ["api", f"repos/{REPO}/issues/comments/42", "--method", "DELETE"]) + + def test_foreign_marker_race_removes_only_this_invocations_new_owned_marker(self): + created = self.marker_comment(42) + foreign = self.marker_comment(10, "someone-else") + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=None), + mock.patch.object(self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[foreign, created]), + mock.patch.object( + self.sync_pr_preview_comment, + "validate_current_head", + side_effect=[None, RuntimeError("PR advanced after our POST")], + ) as validate, + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=[created, created, None]) as api, + ): + with self.assertRaisesRegex(RuntimeError, re.escape("owned by someone-else")): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + self.assertEqual(api.call_args.args[0], ["api", f"repos/{REPO}/issues/comments/42", "--method", "DELETE"]) + validate.assert_called_once_with(REPO, 2517, "head123") + self.assertTrue(all(call.args[0][1] != f"repos/{REPO}/issues/comments/10" for call in api.call_args_list)) + + def test_duplicate_is_revalidated_before_deletion(self): + canonical = self.marker_comment(10) + duplicate = self.marker_comment(42) + changed = self.marker_comment(42, "someone-else") + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=canonical), + mock.patch.object( + self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[canonical, duplicate] + ), + mock.patch.object(self.sync_pr_preview_comment, "validate_current_head"), + mock.patch.object( + self.sync_pr_preview_comment, "gh_json", side_effect=[canonical, canonical, changed] + ) as api, + ): + with self.assertRaisesRegex(RuntimeError, re.escape("No comment was deleted")): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + self.assertFalse(any("DELETE" in call.args[0] for call in api.call_args_list)) + + def test_repurposed_owned_marker_is_not_overwritten(self): + original = self.marker_comment(42) + repurposed = {**original, "body": "Manual review notes"} + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=original), + mock.patch.object(self.sync_pr_preview_comment, "validate_current_head") as validate, + mock.patch.object(self.sync_pr_preview_comment, "gh_json", return_value=repurposed) as api, + ): + with self.assertRaisesRegex(RuntimeError, re.escape("No comment was updated")): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + api.assert_called_once_with(["api", f"repos/{REPO}/issues/comments/42"]) + validate.assert_not_called() + + def test_concurrent_duplicate_cleanup_tolerates_already_deleted_targets(self): + canonical = self.marker_comment(10) + missing = self.marker_comment(42) + remaining = self.marker_comment(43) + for deleted_during in ("lookup", "delete"): + with self.subTest(deleted_during=deleted_during): + responses = [canonical, canonical] + if deleted_during == "delete": + responses.append(missing) + responses.extend([RuntimeError("gh: Not Found (HTTP 404)"), remaining, None]) + with ( + mock.patch.object( + self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=canonical + ), + mock.patch.object( + self.sync_pr_preview_comment, + "fetch_marker_comments", + return_value=[canonical, missing, remaining], + ), + mock.patch.object(self.sync_pr_preview_comment, "validate_current_head"), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=responses) as api, + redirect_stdout(io.StringIO()), + ): + self.sync_pr_preview_comment.upsert_comment(REPO, 2517, "preview body", "preview-owner", "head123") + self.assertEqual( + api.call_args.args[0], ["api", f"repos/{REPO}/issues/comments/43", "--method", "DELETE"] + ) + + def test_head_advance_during_paginated_lookup_stops_before_marker_mutation(self): + for existing in (None, self.marker_comment(42)): + for changed_head in ("remote", "local"): + with self.subTest(existing=existing, changed_head=changed_head): + lookup_finished = False + + def response(args, existing=existing, changed_head=changed_head): + nonlocal lookup_finished + endpoint = args[1] + if endpoint.endswith("comments?per_page=100&page=1"): + return [{"id": 1, "body": "unrelated comment"}] + if endpoint.endswith("comments?per_page=100&page=2"): + return [existing] if existing else [] + if endpoint.endswith("comments?per_page=100&page=3"): + return [] + if endpoint == f"repos/{REPO}/issues/comments/42" and len(args) == 2: + return existing + if endpoint == f"repos/{REPO}/pulls/2517": + lookup_finished = True + return {"head": {"sha": "advanced456" if changed_head == "remote" else "head123"}} + self.fail(f"Unexpected mutation after stale lookup: {args}") + + with ( + mock.patch.object( + self.sync_pr_preview_comment, + "current_head_sha", + return_value="advanced456" if changed_head == "local" else "head123", + ), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=response) as api, + ): + with self.assertRaisesRegex(RuntimeError, re.escape("head changed")): + self.sync_pr_preview_comment.upsert_comment( + REPO, 2517, "preview body", "preview-owner", "head123" + ) + self.assertTrue(lookup_finished) + self.assertFalse(any("--method" in call.args[0] for call in api.call_args_list)) + + def test_delete_comment_deletes_only_revalidated_owned_marker(self): + comment = self.marker_comment(42) + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[comment]), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=[comment, None]) as gh_json, + redirect_stdout(io.StringIO()), + ): + self.sync_pr_preview_comment.delete_comment(REPO, 2517, "preview-owner") + self.assertEqual( + [call.args[0] for call in gh_json.call_args_list], + [ + ["api", f"repos/{REPO}/issues/comments/42"], + ["api", f"repos/{REPO}/issues/comments/42", "--method", "DELETE"], + ], + ) + + def test_explicit_delete_preserves_foreign_markers_while_removing_own_marker(self): + owned = self.marker_comment(42) + foreign = self.marker_comment(10, "someone-else") + with ( + mock.patch.object(self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[foreign, owned]), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=[owned, None]) as api, + redirect_stdout(io.StringIO()), + ): + self.sync_pr_preview_comment.delete_comment(REPO, 2517, "preview-owner") + self.assertEqual(api.call_args.args[0], ["api", f"repos/{REPO}/issues/comments/42", "--method", "DELETE"]) + self.assertTrue(all(call.args[0][1] != f"repos/{REPO}/issues/comments/10" for call in api.call_args_list)) + + def test_parser_exposes_explicit_delete(self): + args = self.sync_pr_preview_comment.build_parser().parse_args(["--delete"]) + self.assertTrue(args.delete) + self.assertFalse(hasattr(args, "delete_missing")) + + def test_missing_local_images_leave_published_previews_untouched(self): + stderr = io.StringIO() + with ( + mock.patch.object( + sys, + "argv", + ["sync_pr_preview_comment.py", "--repo", REPO, "--pr", "2517", "--branch", "codex/example-branch"], + ), + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files", return_value=[]), + mock.patch.object(self.sync_pr_preview_comment, "gh_json") as gh_json, + mock.patch.object(self.sync_pr_preview_comment, "upsert_comment") as upsert, + mock.patch.object(self.sync_pr_preview_comment, "delete_comment") as delete, + redirect_stderr(stderr), + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 1) + self.assertIn("published previews are unchanged", stderr.getvalue()) + gh_json.assert_not_called() + upsert.assert_not_called() + delete.assert_not_called() + + def test_unsupported_inferred_repository_host_stops_before_mutations(self): + stderr = io.StringIO() + with ( + mock.patch.object( + sys, "argv", ["sync_pr_preview_comment.py", "--pr", "2517", "--branch", "codex/example-branch"] + ), + mock.patch.object( + self.sync_pr_preview_comment, + "gh_json", + return_value={"nameWithOwner": REPO, "url": f"https://enterprise.example/{REPO}"}, + ) as gh_json, + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files") as list_files, + mock.patch.object(self.sync_pr_preview_comment, "upload_preview_files") as upload, + mock.patch.object(self.sync_pr_preview_comment, "upsert_comment") as upsert, + mock.patch.object(self.sync_pr_preview_comment, "delete_comment") as delete, + redirect_stderr(stderr), + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 1) + self.assertIn("github.com", stderr.getvalue().lower()) + gh_json.assert_called_once_with(["repo", "view", "--json", "nameWithOwner,url"]) + list_files.assert_not_called() + upload.assert_not_called() + upsert.assert_not_called() + delete.assert_not_called() + + def test_explicit_delete_does_not_require_local_images(self): + with ( + mock.patch.object(sys, "argv", ["sync_pr_preview_comment.py", "--repo", REPO, "--pr", "2517", "--delete"]), + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", return_value={"login": "preview-owner"}), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files") as list_files, + mock.patch.object(self.sync_pr_preview_comment, "delete_comment") as delete, + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 0) + list_files.assert_not_called() + delete.assert_called_once_with(REPO, 2517, "preview-owner") + + def test_mismatched_pr_head_prevents_upload(self): + preview = self.create_preview() + stderr = io.StringIO() + with ( + mock.patch.object( + sys, + "argv", + ["sync_pr_preview_comment.py", "--repo", REPO, "--pr", "2517", "--branch", "codex/example-branch"], + ), + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files", return_value=[preview]), + mock.patch.object(self.sync_pr_preview_comment, "load_capture_metadata", return_value={}), + mock.patch.object( + self.sync_pr_preview_comment, + "gh_json", + side_effect=[{"id": 123, "permissions": {"push": True}}, {"head": {"sha": "remote123"}}], + ), + mock.patch.object(self.sync_pr_preview_comment, "current_head_sha", return_value="local123"), + mock.patch.object(self.sync_pr_preview_comment, "upload_preview_files") as upload, + mock.patch.object(self.sync_pr_preview_comment, "upsert_comment") as upsert, + redirect_stderr(stderr), + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 1) + self.assertIn("Local HEAD must match the PR head", stderr.getvalue()) + upload.assert_not_called() + upsert.assert_not_called() + + def test_main_uploads_native_attachments_and_creates_marker_after_head_recheck(self): + preview = self.create_preview() + captures = {BEFORE_PATH: {"head_sha": "capture123", "dirty": False}} + with ( + mock.patch.object( + sys, + "argv", + ["sync_pr_preview_comment.py", "--repo", REPO, "--pr", "2517", "--branch", "codex/example-branch"], + ), + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files", return_value=[preview]), + mock.patch.object(self.sync_pr_preview_comment, "load_capture_metadata", return_value=captures), + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=None), + mock.patch.object( + self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[self.marker_comment(42)] + ), + mock.patch.object(self.sync_pr_preview_comment, "current_head_sha", return_value="head123"), + mock.patch.object( + self.sync_pr_preview_comment, + "gh_json", + side_effect=[ + {"id": 123, "permissions": {"push": True}}, + {"head": {"sha": "head123"}, "body": "Existing PR description"}, + {"login": "preview-owner"}, + {"url": BEFORE_URL}, + {"head": {"sha": "head123"}}, + {"head": {"sha": "head123"}}, + {"id": 42, "html_url": "https://github.com/comment/42"}, + ], + ) as gh_json, + redirect_stdout(io.StringIO()), + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 0) + calls = gh_json.call_args_list + self.assertTrue(calls[3].args[0][1].startswith("https://uploads.github.com/user-attachments/assets?")) + self.assertEqual(calls[4].args[0], ["api", f"repos/{REPO}/pulls/2517"]) + self.assertEqual(calls[5].args[0], ["api", f"repos/{REPO}/pulls/2517"]) + comment_args = calls[6].args[0] + self.assertEqual(comment_args[1], f"repos/{REPO}/issues/2517/comments") + self.assertEqual(comment_args[comment_args.index("--method") + 1], "POST") + body = json.loads(calls[6].kwargs["input_text"])["body"] + self.assertIn(self.pr_preview.MARKER_COMMENT, body) + self.assertIn(BEFORE_URL, body) + self.assertIn("capture123", body) + self.assertIn("head123", body) + self.assertIn(BEFORE_URL, (preview.parent / "attachments.json").read_text()) + + def publish_during_recapture(self, *, mutate_cache=False): + preview = self.create_preview() + original_digest = self.pr_preview.preview_file_digest(preview) + capture_path = preview.parent / "captures.json" + self.pr_preview.write_preview_metadata( + capture_path, + { + BEFORE_PATH: { + "sha256": original_digest, + "head_sha": "before123", + "dirty": False, + "source_fingerprint": "a" * 64, + } + }, + ) + + def gh_response(args, *, input_text=None): + endpoint = args[1] + if endpoint == f"repos/{REPO}": + return {"id": 123, "permissions": {"push": True}} + if endpoint == f"repos/{REPO}/pulls/2517": + return {"head": {"sha": "head123"}} + if endpoint == "user": + return {"login": "preview-owner"} + if endpoint.startswith("https://uploads.github.com/user-attachments/assets?"): + snapshot = Path(args[args.index("--input") + 1]) + self.assertEqual(snapshot.read_bytes(), PNG) + preview.write_bytes(PNG + b"recaptured") + self.pr_preview.write_preview_metadata( + capture_path, + { + BEFORE_PATH: { + "sha256": self.pr_preview.preview_file_digest(preview), + "head_sha": "recapture456", + "dirty": True, + "source_fingerprint": "b" * 64, + } + }, + ) + self.assertEqual(snapshot.read_bytes(), PNG) + return {"url": BEFORE_URL} + if endpoint == f"repos/{REPO}/issues/2517/comments": + self.assertIsNotNone(input_text) + return {"id": 42, "html_url": "https://github.com/comment/42"} + self.fail(f"Unexpected GitHub request: {args}") + + upload_preview_files = self.sync_pr_preview_comment.upload_preview_files + + def upload_then_mutate_cache(repo, repository_id, preview_files): + attachments, uploaded_digests = upload_preview_files(repo, repository_id, preview_files) + if mutate_cache: + self.pr_preview.write_preview_metadata( + preview.parent / "attachments.json", + { + "repo": repo, + "files": { + BEFORE_PATH: { + "sha256": self.pr_preview.preview_file_digest(preview), + "url": BEFORE_URL, + } + }, + }, + ) + return attachments, uploaded_digests + + with ( + mock.patch.object( + sys, + "argv", + ["sync_pr_preview_comment.py", "--repo", REPO, "--pr", "2517", "--branch", "codex/example-branch"], + ), + mock.patch.object(self.pr_preview, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files", return_value=[preview]), + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=None), + mock.patch.object( + self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[self.marker_comment(42)] + ), + mock.patch.object(self.sync_pr_preview_comment, "current_head_sha", return_value="head123"), + mock.patch.object(self.sync_pr_preview_comment, "gh_json", side_effect=gh_response) as gh_json, + mock.patch.object( + self.sync_pr_preview_comment, "upload_preview_files", side_effect=upload_then_mutate_cache + ), + redirect_stdout(io.StringIO()), + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 0) + comment_body = json.loads(gh_json.call_args.kwargs["input_text"])["body"] + uploaded = json.loads((preview.parent / "attachments.json").read_text()) + return comment_body, uploaded, original_digest + + def test_recapture_during_upload_preserves_snapshot_and_does_not_mislabel_source(self): + comment_body, uploaded, original_digest = self.publish_during_recapture() + self.assertIn(BEFORE_URL, comment_body) + self.assertIn("Capture revision unavailable.", comment_body) + self.assertNotIn("recapture456", comment_body) + self.assertEqual(uploaded["files"][BEFORE_PATH]["sha256"], original_digest) + + def test_mutable_cache_after_upload_cannot_relabel_published_capture_source(self): + comment_body, uploaded, original_digest = self.publish_during_recapture(mutate_cache=True) + self.assertNotEqual(uploaded["files"][BEFORE_PATH]["sha256"], original_digest) + self.assertIn(BEFORE_URL, comment_body) + self.assertIn("Capture revision unavailable.", comment_body) + self.assertNotIn("recapture456", comment_body) + + def test_pr_head_changes_during_upload_leave_cached_assets_and_existing_comment(self): + preview = self.create_preview() + stderr = io.StringIO() + with ( + mock.patch.object( + sys, + "argv", + ["sync_pr_preview_comment.py", "--repo", REPO, "--pr", "2517", "--branch", "codex/example-branch"], + ), + mock.patch.object(self.sync_pr_preview_comment, "repo_root", return_value=self.temp_repo_root), + mock.patch.object(self.sync_pr_preview_comment, "require_attachment_support"), + mock.patch.object(self.sync_pr_preview_comment, "list_preview_files", return_value=[preview]), + mock.patch.object(self.sync_pr_preview_comment, "load_capture_metadata", return_value={}), + mock.patch.object(self.sync_pr_preview_comment, "fetch_existing_marker_comment", return_value=None), + mock.patch.object( + self.sync_pr_preview_comment, "fetch_marker_comments", return_value=[self.marker_comment(42)] + ), + mock.patch.object(self.sync_pr_preview_comment, "current_head_sha", return_value="head123"), + mock.patch.object( + self.sync_pr_preview_comment, + "gh_json", + side_effect=[ + {"id": 123, "permissions": {"push": True}}, + {"head": {"sha": "head123"}}, + {"login": "preview-owner"}, + {"url": BEFORE_URL}, + {"head": {"sha": "advanced456"}}, + ], + ) as gh_json, + mock.patch.object(self.sync_pr_preview_comment, "upsert_comment") as upsert, + redirect_stderr(stderr), + ): + exit_code = self.sync_pr_preview_comment.main() + + self.assertEqual(exit_code, 1) + self.assertIn("head changed during upload", stderr.getvalue()) + self.assertEqual(gh_json.call_count, 5) + self.assertIn(BEFORE_URL, (preview.parent / "attachments.json").read_text()) + upsert.assert_not_called() diff --git a/tests/test_shipped_catalog.py b/tests/test_shipped_catalog.py new file mode 100644 index 0000000..9ed0d19 --- /dev/null +++ b/tests/test_shipped_catalog.py @@ -0,0 +1,41 @@ +"""Check actual distributed capabilities, not only synthetic installer fixtures.""" + +import re +import unittest + +import yaml + +from agentics_tools.catalog import default_catalog +from agentics_tools.validation import validate + + +class ShippedCatalogTests(unittest.TestCase): + def test_all_installed_markdown_links_resolve(self): + validate(default_catalog()) + + def test_skill_ui_metadata_and_invocation_policies_are_valid(self): + catalog = default_catalog() + for key, item in catalog.items.items(): + if item["kind"] != "skill": + continue + with self.subTest(skill=key): + bundle = catalog.bundle(key) + metadata = yaml.safe_load(bundle["agents/openai.yaml"][0]) + interface = metadata["interface"] + self.assertIn(f"${item['name']}", interface["default_prompt"]) + self.assertTrue(25 <= len(interface["short_description"]) <= 64) + if item["name"] == "pr-review": + self.assertIs(metadata["policy"]["allow_implicit_invocation"], False) + header = re.match(r"---\n(.*?)\n---", bundle["SKILL.md"][0].decode(), re.S).group(1) + frontmatter = yaml.safe_load(header) + self.assertEqual(frontmatter["name"], item["name"]) + self.assertIsInstance(frontmatter["description"], str) + + def test_public_catalog_omits_workstation_paths_and_numeric_service_ids(self): + catalog = default_catalog() + # Keep the check itself publishable: never embed real private identifiers. + private_metadata = re.compile(r"/(?:Users|home)/[^\s/]+|\b\d{12,20}\b") + for key in catalog.items: + for name, (contents, _) in catalog.bundle(key).items(): + with self.subTest(item=key, resource=name): + self.assertNotRegex(contents.decode(), private_metadata) diff --git a/workflows/code-health-scout.md b/workflows/code-health-scout.md new file mode 100644 index 0000000..c9dc08a --- /dev/null +++ b/workflows/code-health-scout.md @@ -0,0 +1,137 @@ +# Code Health Scout Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Run the repository cleanup scouts as one issue-triage workflow: complexity hotspots, Desloppify review, and code hygiene reports feed one deduplicated tech-debt issue queue. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | code-health-scout | +| Display name | Code Health Scout | +| Supersedes | complexity-ticket-scout, desloppify-scan, code-hygiene-scout | +| Shared guidance | ../automation-guidelines.md | + +## Scope + +This Markdown file is the single scout workflow contract used by the orchestrator for repo cleanup issue discovery. Shared unattended automation rules live in `../automation-guidelines.md`; this file owns the combined scout scope, output contracts, validation expectations, and blocker behavior. + +This lane is issue-triage only. It may gather evidence, write ignored report outputs through the documented scan commands, dedupe findings, create or update GitHub issues, update allowed memory, and return queued follow-up. It must not edit tracked repo files, create branches, push commits, open pull requests, mutate production/live services, or perform cleanup fixes inline. + +## Outputs And Markers + +Preserve the active issue markers and title prefixes so existing issues, closed regressions, active PRs, and historical lane memory keep deduping correctly. + +| Family | Issue title prefix | Hidden marker | Required issue sections | +| --- | --- | --- | --- | +| Complexity | `[Complexity]` | `` | `### Summary`, `### Complexity Evidence`, `### Suggested Cleanup`, `### Acceptance Criteria`, `### Validation Notes` | +| Desloppify | `[Desloppify]` | `` | `### Summary`, `### Priority`, `### Where`, `### Why It Matters`, `### Recommended Change`, `### Timing`, `### Evidence`, `### Acceptance Criteria`, `### Validation Notes` | +| Code Hygiene | `[Code Hygiene]` | `` | `### Summary`, `### Evidence`, `### Suggested Cleanup`, `### Acceptance Criteria`, `### Validation Notes` | + +Use the repository's existing automation/debt labels and product mappings when its guidance defines them; do not invent missing labels. + + +## Memory + +- Dedicated discussion title for standalone runs: `Automation Memory - Code Health Scout`. +- Dedicated discussion body marker for standalone runs: ``. +- Historical dedicated discussions for Code Quality Scout, Complexity Ticket Scout, Desloppify Scan, and Code Hygiene Scout are read-only dedupe inputs; Code Quality is no longer an active scout family. +- Memory updates must record the project-local date, scanned default-branch SHA, eligible families, skipped families, report paths, source snapshots, candidate counts, issues created or updated, blockers, queued overflow candidates, completed family snapshots, and next eligible times. + +## Workflow + +Follow the lane contract below from startup through evidence gathering, dedupe, issue selection, and outcome handling. When the orchestrator dispatches this lane, use this Markdown file as the lane-specific prompt source and run all eligible scout families inside this one worker. + +## Validation + +Use `../automation-guidelines.md` plus the family-specific validation commands and gates named below. Do not publish issues, comments, discussions, or memory updates unless the evidence and output rules allow it. + +## Blockers + +Use the blocked-case and unclear-case rules below. Prefer a no-op, queued follow-up, active-memory blocker, or concise GitHub blocker over speculative writes. + +## Lane Contract + +### Repository and ownership scope + +- Treat GitHub Issues as the canonical backlog and the primary report artifact. +- Run one combined issue-triage pass across complexity, Desloppify, and code hygiene evidence instead of dispatching separate scout workers. +- Treat all findings as issue-scout evidence, not automatic permission to refactor. Code-changing cleanup happens later through bounded debt workers assigned to concrete issues. +- Respect the orchestrator's per-run GitHub issue create/update budget. Standalone runs should also cap writes at 15 total issues/comments across all families. +- Prefer no-op over speculative cleanup tickets. Create or update an issue only when the finding cluster is bounded, high-confidence, and not already covered. +- Do not paste long source excerpts, secrets, tokens, private customer data, unnecessary raw logs, IP addresses, emails, session identifiers, or full stack traces into issues or memory. + +### Eligibility and cadence + +Use cadence as a fallback for background discovery only. Explicit maintainer requests, queued active-memory follow-up, changed tool evidence, completed selected cleanup tasks that need backlog redisplay, and materially changed source snapshots can run immediately. + +| Family | Background cadence | Run when | +| --- | --- | --- | +| Complexity | One completed scout per project-local calendar date | No same-date completed run exists, queued follow-up exists, new complexity evidence justifies a rerun, or an explicit request asks for complexity discovery. | +| Desloppify | One completed whole-repo scan per project-local calendar week | No same-scope scan covers the current default-branch SHA within cadence, the active memory surface queues a refresh, a selected Desloppify issue completes and remaining backlog should be redisplayed, or an explicit request asks for a Desloppify scan. | +| Code Hygiene | One completed scout per default-branch head SHA and tool snapshot; weekly fallback | No same-scope scout covers the current default-branch head SHA within the week, the active memory surface queues hygiene follow-up or overflow, tool output materially changes, or an explicit request asks for a code hygiene scan. | + +If no family is eligible after memory, live-state, and explicit-request checks, return a no-op summary that names the next eligible family and time. + +### Evidence families + +#### Complexity + +- Run the configured complexity-report command. +- Write reports to ignored paths such as `.tmp/complexity-hotspots.md` or `/tmp/automation-complexity-hotspots-YYYY-MM-DD.md`. Do not commit report artifacts. +- Build candidates from top functions and files first. Use top systems only to guide grouping; do not create broad system-wide tickets unless the report points to one coherent bounded area. +- Complexity alone is not enough. Inspect enough nearby code, churn, and open PR/issue state to confirm the cleanup target is real and bounded. + +#### Desloppify + +- Review maintainability, duplication, naming and structure consistency, fragile assumptions, missing error handling, UI/UX rough edges, security or validation issues, dead code, unused files, confusing architecture, and painful future maintenance risks. +- Inspect representative repository structure, recent churn, open issues/PRs, tests, templates, app docs, Code Health Scout report outputs, and existing automation outcomes to avoid stale, duplicated, or already-covered findings. +- Prefer concrete, file-backed items over broad impressions. Each issue must name repo-relative paths, functions, components, workflows, docs surfaces, or issue/PR links that make the cleanup actionable. +- Priorities are `Critical`, `Medium Cleanup`, and `Nice-To-Have Polish`. +- Timing values are `Safe to fix now`, `Fix after `, or `Wait for human decision`. + +#### Code Hygiene + +- Run the configured code-hygiene-report command. +- Inspect the fresh report paths emitted by the configured native command. Do not assume another project's artifact layout or commit report artifacts. +- Build candidates from high-confidence clusters first: + - dead code or unused file/export/dependency findings from Vulture, Deptry, or Knip + - duplicated local source modules from jscpd + - repeated broad Ruff smell-rule findings in the same file or cohesive area + - diff-cover coverage cliffs only when Cobertura artifacts are present + - repository-specific static-analysis findings when its report configuration includes them +- Treat absent coverage artifacts as a non-blocking skipped section. Missing tools or configuration required by the selected native report block issue creation from stale data; optional report sections may be explicitly skipped. + +### Dedupe and issue writes + +1. Determine the current date in the project timezone, the fetched configured default-branch ref commit SHA, current tool/source snapshots, queued follow-up, and explicit request scope. +2. Read the active memory surface and useful historical lane memory before writing. Historical family discussions are dedupe inputs, not new write targets. +3. Build one candidate inventory across all eligible families before creating or updating any issues. +4. Search GitHub issues in `OWNER/REPO` across all states before each write, in this order: + - the family-specific hidden marker from the Outputs And Markers table + - for jscpd report findings only, use `identity_paths` for an all-state file-pair search before any title fallback; search each exact repo-relative path and accept a candidate only when its body or evidence identifies the same unordered pair, never from a primary-path-only title match + - when one exact-pair issue still has a legacy line-derived marker, replace that marker with the finding's canonical `key` during the same update + - when multiple exact-pair issues collapse to one canonical key, keep the lowest-numbered open issue as canonical; if none are open, apply the closed-regression rule below to the lowest-numbered closed issue; link and close the extras as duplicates or superseded before writing the canonical marker to only the retained issue + - a close title match for the same file, dependency, rule ID, function, component, route, workflow, or cohesive area + - open `tech-debt` issues and active PRs that already cover the same path or cleanup scope +5. Before treating the jscpd key migration as complete, independently inventory all-state issues containing the legacy marker prefix ``. +- Record the last reviewed commit boundary, selected candidate or no-op result, blocker state, and any PR link. + +### Execution guardrails +- Use only concrete repo evidence such as commit SHAs, file paths, diffs, failing tests, CI signals, or reproduced behavior. +- Do not invent bugs. If the evidence is weak, conflicting, or speculative, skip the candidate. +- Prefer the smallest safe fix for each candidate. Avoid broad refactors, cleanup-only edits, or unrelated multi-item bundles. +- Keep edits within the assigned branch and scope. Follow the user/repository checkout strategy; enabled unattended code-changing runs require an isolated worktree. + +### Per-run workflow +1. Read the active memory surface, inspect recent landed changes since the last reviewed boundary captured there or the trailing 24 hours when no usable boundary exists, and exclude ranges already under active PR review. +2. Select up to 5 high-confidence bug candidates supported by concrete repo evidence. +3. Partition the selected candidates into work items: group related bugs only when they share the same failing path or implementation surface, and keep distinctly different bugs in separate work items. +4. For each work item, use the assigned safe workspace/branch under the user's and repository's strategy. Enabled unattended runs use isolated worktrees; manually requested current-checkout work preserves unrelated changes. +5. If the real failing path is still unclear for a work item, use `$runtime-probe` before editing. +6. If a fix touches migrations, startup behavior, deploy workflows, or `infrastructure/`, use `$deploy-safety` before editing that work item. +7. Use `$change-plan` before cross-surface, compatibility-sensitive, or deploy-sensitive fixes. +8. Implement the smallest safe fix for the work item in its assigned workspace/branch. +9. Use `$change-tests` when the fix needs focused regression coverage near the failing path. +10. If the fix crosses the major-change threshold described in the repository standards and the installed `$change-audit` skill, run `$change-audit` before final validation and handoff. If that audit surfaces canonical doc drift tied to the fix, run `$change-docs` in the same branch. +11. Use `$change-verify` before publishing each fix. +12. Produce `$pr-handoff`, then run `$pr-metadata` to finalize the PR title, body, and labels for each branch. +13. If validation succeeds, open a ready-for-review PR with `$github:yeet` using the `$pr-metadata` title, body, and labels. The title must keep the `Daily Bug Scan:` prefix and the label set must include `automation` when the available GitHub tools allow it. +14. Record the reviewed boundary, every selected candidate, grouped work item, outcome, and any PR links through the active memory surface. + +### Blocked or unclear cases +- If no high-confidence bug candidate exists, stop without error. +- If overlap with an open PR, missing repo evidence, or an unclear failure path prevents a safe fix for a candidate, skip that candidate. +- If validation fails for one work item, do not publish that PR; continue only if the failure is isolated and does not invalidate the remaining selected work. +- Exclude tool-generated or unrelated files from any PR. +- Record a concise no-op or blocker through the active memory surface when the run ends without a PR. + +### Execution norms +- Fix up to 5 bug items per run, with at most 5 PRs per run. +- Open one PR per distinct bug or coherent related group; do not combine unrelated bug fixes into the same PR. +- Stop before the cap when evidence, validation confidence, PR overlap, or time budget makes additional candidates unsafe. +- Prefer no-op over speculative or partially validated changes. +- Do not open a PR with partial work, guessed fixes, or unvalidated scope. diff --git a/workflows/daily-changes-digest.md b/workflows/daily-changes-digest.md new file mode 100644 index 0000000..c93888f --- /dev/null +++ b/workflows/daily-changes-digest.md @@ -0,0 +1,101 @@ +# Daily Change Summary Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Publish exactly one dated maintainer summary discussion for the previous project-local calendar day. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | daily-changes-digest | +| Display name | Daily Change Summary | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and external systems +- This automation is reporting-only. Never edit repo files, branches, pull requests, GitHub issues, GitHub issue comments, Dokploy state, or Sentry state. +- Use GitHub as the source of truth for pull requests, issues, workflow and deploy signals, and GitHub Discussions reads and writes. +- Discover the repository's configured read-only Sentry connector/API and verify access with a lightweight read. Managed connector access does not require a shell token. +- Post the report to the existing GitHub Discussions category with slug `daily` (the configured repository daily discussion category). +- The reporting window is the previous local calendar day in the project timezone as a half-open interval: `[00:00:00 for that date, 00:00:00 for the next local date)`. Convert both bounds to UTC when querying GitHub or Sentry APIs. + +### Execution guardrails +- If no configured read-only Sentry access is available, continue with GitHub-only reporting and record the gap in `## Notes / Data Gaps`; do not request credentials or reconfigure access during the digest. +- Never create a second discussion for the same report date. Reuse the existing discussion when the exact title or the body marker already exists. +- If the matching discussion exists but cannot be updated safely because it is locked, missing required permissions, or otherwise non-editable, stop without creating a duplicate. +- Do not duplicate live deploy verification. Use GitHub workflow and deployment evidence plus any existing deploy follow-up issues. + +### Discussion contract +- Title format: `Daily Change Summary - YYYY-MM-DD`, where the date is the previous local date in the project timezone. +- The discussion body must begin with ``. +- The dated daily discussion is the standalone history and dedupe surface as well as the required report output. Under orchestrator dispatch, central orchestrator memory remains the active memory surface. +- The body must contain these sections in order: + - `## TL;DR` + - `## Needs Your Attention` + - `## Merged / Shipped` + - `## Tickets Created` + - `## Tickets Closed` + - `## New Bugs` + - `## Deploys` + - `## Notes / Data Gaps` +- Always emit every listed section header in that exact order. If a section has nothing to report, include the header and a single line containing `None`. +- Quiet days still create or update the dated discussion. On a quiet day, keep `## TL;DR` concise, make `## Needs Your Attention` explicitly state that nothing urgent needs review, and use `None` for any other section with no items. + +### Per-run workflow +1. Determine the report date and exact UTC query window for the previous local day in the project timezone, using the half-open interval `[local 00:00:00, next local 00:00:00)`. +2. Resolve the `daily` discussion category id from the repository before any write. +3. Search existing repository discussions for either: + - the exact title `Daily Change Summary - YYYY-MM-DD`, or + - the body marker ``. +4. Gather GitHub activity inside the window: + - pull requests merged into the repository during the window + - open pull requests that currently need attention because they have failing checks, requested review changes, merge conflicts, or other concrete blockers, but only include them when they changed during the window or the blocking signal first appeared during the window + - GitHub issues created during the window + - GitHub issues closed during the window + - GitHub issues created during the window with the `bug` label + - automation-created blocker issues created or updated during the window when they imply maintainer follow-up, including `deploy-verifier`, `sentry-daily-triage`, and similar reporting issues + - `cd.yml` workflow runs or equivalent production deploy workflow results that completed during the window +5. Gather Sentry signals only when credentials are available: + - newly seen or materially spiking issue groups whose first seen or high-signal activity falls inside the window + - prefer high-signal groups only: production incidents, auth or payment blockers, deploy-correlated spikes, or groups with enough event volume to likely need human triage + - if a matching GitHub issue from `sentry-daily-triage` already exists, prefer linking to that issue as the stable artifact instead of repeating raw event detail +6. Build `## Needs Your Attention` from actionable blockers only. Include: + - open pull requests with failing checks, requested changes, merge conflicts, or other clear blockers + - deploy outcomes that failed or need warning-level follow-up + - new high-signal Sentry incidents or new GitHub bug issues that likely require maintainer triage + - fresh automation-created blocker issues that point to real follow-up work + - do not turn this section into a full activity dump +7. Build the remaining sections: + - `## TL;DR`: a short dated summary of the day, including counts for merged pull requests, tickets created and closed, new bugs, deploy results, and whether attention items exist + - `## Merged / Shipped`: merged pull requests from the window, plus shipped-to-production notes when GitHub deploy evidence clearly ties a deployed SHA back to a merged pull request + - `## Tickets Created`: GitHub issues created in the window, excluding pull requests + - `## Tickets Closed`: GitHub issues closed in the window, excluding pull requests + - `## New Bugs`: newly opened GitHub bug issues and new high-signal Sentry issues first seen in the window + - `## Deploys`: production deploy workflow results from the window and any linked deploy warning or failure issue that already exists + - `## Notes / Data Gaps`: missing Sentry access, ambiguous deploy linkage, incomplete API reads, or a clear quiet-day note when activity was minimal +8. Keep the output concise and maintainer-facing: + - use flat bullets + - include direct GitHub or Sentry links where available + - prefer one line per item with the most useful signal first + - always emit every required section header in the fixed order above; when a section has nothing to report, include the header and a single line containing `None` +9. Write the discussion: + - if a matching discussion already exists and remains editable, update its body in place + - otherwise create exactly one new discussion in the `daily` category with the required title and body + +### Blocked cases +- If GitHub discussion reads or writes fail, stop without partial retries and do not write or update any discussion, to avoid duplicates. +- If core GitHub activity reads fail, continue only when enough other successfully read evidence still exists to produce a useful report; in that case, write a partial report and mention the missing reads only inside `## Notes / Data Gaps`. +- If core GitHub activity reads fail and enough other evidence does not exist to produce a useful report, stop without writing. +- If all relevant data sources fail and the discussion would be mostly fabricated, stop without writing. +- If the dated discussion cannot be reused safely, stop instead of falling back to local memory. + +### Execution norms +- One discussion maximum per report date. +- Prefer no-op over duplicate or low-confidence reporting. +- Do not create follow-up issues, pull requests, code changes, or comments from this automation. diff --git a/workflows/docs-cohesion-keeper.md b/workflows/docs-cohesion-keeper.md new file mode 100644 index 0000000..2f533c0 --- /dev/null +++ b/workflows/docs-cohesion-keeper.md @@ -0,0 +1,57 @@ +# Docs Cohesion Keeper Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Maintain high-confidence internal repo documentation cohesion with small docs-only PRs. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | docs-cohesion-keeper | +| Display name | Docs Cohesion Keeper | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and documentation scope +- Maintain only the human-facing documentation surfaces described below. + +### Discussion memory +- Discussion title: `Automation Memory - Docs Cohesion Keeper`. +- Discussion body must begin with ``. +- Record the selected drift items or no-op result, blocker state, and any PR link. + +### Execution guardrails +- This automation maintains human-facing repo docs only. Never edit code, tests, dependency manifests, skill definitions, agent definitions, scheduler adapters, the installed workflow resources, or GitHub issue text. +- Treat `installed skill entrypoints`, `agent definitions.toml`, the installed workflow resources*.md, and relevant app docs under the configured product documentation as source-of-truth inputs when repo docs describe those surfaces. +- Before choosing work, inspect open pull requests and skip any documentation drift already covered by an open PR or active automation PR. + +### Per-run workflow +1. Read the active memory surface, then use `$docs-audit` to identify high-confidence internal documentation drift. +2. Select up to 5 high-confidence docs-only maintenance items for the run. + - Prefer stale workflow references, missing index/router updates, contradictory canonical guidance, duplicated guidance that should be consolidated, or obviously outdated app-doc discoverability. + - Group related findings into one branch when they improve the same doc surface or router/index cohesion. + - Split distinctly different documentation drift into separate work items with separate branches and PRs. + - Skip low-confidence or speculative cleanup. +3. For each work item, use the assigned safe workspace/branch under the user's and repository's strategy. Enabled unattended runs use isolated worktrees; manually requested current-checkout work preserves unrelated changes. +4. If a work item can be completed within the human-facing documentation catalogued by the repository documentation inventory, use `$docs-maintain` to apply the smallest coherent updates across the selected items; skill, custom-agent, and workflow source definitions remain out of scope. +5. Use `$change-verify` on all edited docs once per work item. +6. Produce `$pr-handoff` for each branch, then run `$pr-metadata` to finalize the PR title, body, and labels. +7. Open a ready-for-review PR with `$github:yeet` using the `$pr-metadata` title, body, and labels when the docs-only changes are validated and materially improve clarity or cohesion. Use the configured title prefix and existing labels; `Docs Cohesion Keeper:` and `automation` are compatible adapter defaults, not new label-creation authority. +8. Record every selected drift item, grouped work item, outcome, and any PR links through the active memory surface. + +### Blocked or unclear cases +- If no high-confidence docs-only drift exists, stop without error. +- If a fix would require code changes, behavior changes, unresolved product decisions, or edits to tracked skill/agent/automation definitions, skip that candidate. +- If validation fails for one work item, do not publish that PR; continue only if the failure is isolated and does not invalidate the remaining selected work. +- Record a concise no-op or blocker through the active memory surface when the run ends without a PR. + +### Execution norms +- Primary goal: improve doc cohesion and consistency across canonical repo docs in each run; prefer one validated PR for a small related batch, and separate PRs for distinctly different drift. +- Fix up to 5 docs items per run, with at most 5 PRs per run. +- Prefer no-op over guessing. diff --git a/workflows/merged-pr-comment-sweep.md b/workflows/merged-pr-comment-sweep.md new file mode 100644 index 0000000..0a61f45 --- /dev/null +++ b/workflows/merged-pr-comment-sweep.md @@ -0,0 +1,86 @@ +# Merged PR Comment Sweep Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Find unresolved review threads left on merged PRs, then either create deduplicated follow-up issues for high-severity or high-effort work, or open one validated fix PR per merged source PR. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | merged-pr-comment-sweep | +| Display name | Merged PR Comment Sweep | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and ownership scope +- Inspect merged pull requests authored by `CONFIGURED_AUTHOR` that targeted the configured default branch, including same-repo and fork-origin PRs, because follow-up work starts from the fetched configured default-branch ref. +- Only handle unresolved review threads on PRs that are already merged. Open PRs remain owned by `pr-guardian` and `pr-reviewer`. +- Treat the merged PR as the source grouping key. Do not combine unresolved comments from multiple source PRs into one follow-up issue, branch, or pull request. +- Use `agentics tools --repo-root PATH pr-threads list-merged-prs --repo OWNER/REPO --author CONFIGURED_AUTHOR --limit 50` to discover candidate merged PRs. Without a reliable cursor, the initial scope is the 50 most recent main-targeted merged PRs. With a cursor, add `--merged-since ` using that cursor's date and increase `--limit` whenever the result count reaches the requested limit, until the entire cursor window is discovered. If discovery remains incomplete, retain the previous cursor and record the overflow/blocker for the next run. +- Use `agentics tools --repo-root PATH pr-threads list-unresolved-threads --repo OWNER/REPO --pr ` for each candidate with unresolved review threads. + +### Markers +- Add this hidden marker to every source review-thread reply made by this lane: + `` +- Add this hidden marker to every issue created or updated by this lane: + `` +- Add this hidden marker to every follow-up PR body created by this lane: + `` +- Treat existing live GitHub artifacts with `` as legacy lane-owned markers for dedupe and update/retire decisions during the migration. Do not create new legacy markers. +- Follow-up PR titles must start with `Merged PR Comment Sweep:`. Issue titles should start with `[Merged PR follow-up] PR #:` unless updating an existing matching issue. + +### Discussion memory +- Discussion title: `Automation Memory - Merged PR Comment Sweep`. +- Discussion body must begin with ``. +- Track the last reliable merged-PR cursor, scanned PR numbers and merged SHAs from the `list-merged-prs` `merge_commit_oid` field, represented review-thread IDs, created issue URLs, created follow-up PR URLs, resolved-thread outcomes, skipped stale/no-longer-valid comments, active worker IDs, and blockers. + +### Execution guardrails +- The dispatcher must not perform source-PR-specific classification or fixes inline. It discovers candidate merged PRs, dedupes against memory and GitHub markers, then creates one bounded worker per source PR that needs action. +- For each source PR, use exactly one worker/thread titled `Merged PR Comment Sweep: PR #` when the tool supports titles, or as the first line of the worker prompt when it does not. +- Each source-PR worker may classify every unresolved thread on that merged PR, create or update issue tickets, create at most one follow-up fix PR, reply to source review threads, and resolve represented source review threads when safe. +- Code-changing follow-up work must start from the fetched configured default-branch ref in an isolated branch. It must not check out, merge, rebase, force-push, or mutate the already-merged source PR branch. +- Keep fixes small and cohesive. Do not bundle unrelated cleanup, broad refactors, or comments from unrelated source PRs into the same branch. +- Before creating a new issue or PR, search open issues, open PRs, source review-thread comments, and the active memory surface for the exact current or legacy marker or same source PR/thread IDs. Update existing represented work instead of creating duplicates. + +### Classification rules +- `resolved/no longer valid`: The the fetched configured default-branch ref implementation already satisfies the review feedback, the thread is obsolete, or the comment was explanation-only and no code or issue follow-up is warranted. Reply with the lane marker and a concise rationale, then resolve the thread when the API permits. +- `ticket follow-up`: The feedback is still relevant but high severity, broad, risky, ambiguous, or high effort. Create or update a GitHub issue instead of editing code. High-severity or high-effort includes security/auth/billing/privacy/deploy risk, destructive migrations, unclear product decisions, cross-cutting architecture, flaky or unreproduced failures, large refactors, and work that cannot be validated with a focused repo-approved command. +- `fix-pr follow-up`: The feedback is still relevant on the fetched configured default-branch ref, low risk, and small enough to fix with a focused validation path. Group all compatible `fix-pr follow-up` threads from the same merged source PR into one branch and one PR. +- If a source PR has both ticket-sized and fix-sized comments, create the issue tickets first, then open one fix PR only for the low-risk compatible subset. + +### Issue ticket rules +- Prefer one issue per coherent source-PR follow-up group. Split into multiple issues only when comments describe unrelated domains or validation paths. +- Issue bodies must include the source PR URL, source review-thread/comment URLs, the hidden marker for each represented thread, current-main evidence, severity or effort rationale, acceptance criteria, suggested validation, and known blockers. +- Label issues with `automation` and the most specific existing domain label from the configured label inventory. Add `bug` for correctness, regression, data-loss, security, deploy, or production-risk follow-up. Add `tech-debt` for cleanup, decomposition, maintainability, or testability follow-up. +- After an issue is created or updated and fully represents a review thread, reply to the source review thread with the issue URL and resolve it when the API permits. + +### Fix PR rules +- Use `$change-plan` before cross-surface, compatibility-sensitive, migration, deploy, API, template-plus-JS, or workflow-sensitive fixes. +- Use `$change-tests` when the change has a meaningful regression surface. +- Use `$change-verify` with the smallest repo-approved validation command before publishing. +- Use `$pr-handoff`, then `$pr-metadata` before creating the PR. The PR title must start with `Merged PR Comment Sweep:` and the labels must include `automation` plus the most specific existing domain label when GitHub tooling allows it. +- The PR body must include the source PR URL, source review-thread/comment URLs, the PR hidden marker, and whether any threads from the same source PR were represented by issues instead. Report validation evidence in the operator handoff, placing it in the PR body only when repository metadata policy calls for it. +- After a follow-up PR is created and fully represents a review thread, reply to the source review thread with the PR URL and resolve it when the API permits. If PR creation fails after validated local edits, leave the source thread unresolved, record the blocker, and do not repeat the same reply on later runs. + +### Per-run workflow +1. Read the active memory surface. +2. Discover recently merged PRs using the cursor-window rules above. Advance the cursor to the newest scanned `merged_at` only after the entire required window (or the initial 50-PR scope) has been discovered and every candidate is classified or durably queued. Include the cursor date on the next scan and dedupe by PR number/merged SHA so same-day merges are not skipped. +3. For each candidate with unresolved review threads, call `list-unresolved-threads` and dedupe against open issues, open PRs, current or legacy source-thread marker replies, and active-memory records. A `blocked` marker/outcome is retryable when its blocking evidence or current main changes; retain it in the durable queue rather than treating it as permanently handled. Do not duplicate an active worker or an existing open follow-up issue/PR. +4. Create one source-PR worker for each remaining PR with actionable unrepresented threads, bounded by the active worker cap. Queue the rest by source PR number, merged SHA from `merge_commit_oid`, and unresolved thread IDs. +5. Each source-PR worker inspects the fetched configured default-branch ref, the original review-thread context, nearby code/tests/docs, existing issues and PRs, and any relevant lane memory before classification. +6. Each source-PR worker applies the classification rules, creates issue tickets for high-severity or high-effort work, implements one grouped low-risk fix PR when warranted, and replies/resolves represented source threads. +7. Each worker returns a structured outcome: source PR number and URL, merged SHA from `merge_commit_oid` if known, thread IDs handled, classification per thread, issue URLs, follow-up PR URL, validation commands and results, review-thread reply/resolve results, blockers, cleanup status, and the exact memory update requested. +8. The dispatcher consumes worker outcomes and writes active-memory updates in order. If memory writes fail, stop launching new workers and include the failure in the run outcome. + +### Blocked or unclear cases +- If GitHub auth, memory, spawned-agent/thread tooling, review-thread helper output, or issue/PR search is unavailable, record a blocker and do not create duplicate comments. +- If a review thread cannot be mapped to the fetched configured default-branch ref with enough confidence, create or update a ticket only when the uncertainty itself is actionable; otherwise leave a blocked marker reply and keep the thread unresolved. +- If validation fails for a low-risk fix and a safe repair is unclear, do not publish the PR. Create or update a ticket when the remaining issue is real and actionable; otherwise record the blocker. +- If the source review thread API rejects a reply or resolve operation after an issue or PR was created, record the issue/PR link and API error in memory without retry-spamming the thread. +- If no unrepresented unresolved threads are found, stop without error and record the no-op boundary in memory. diff --git a/workflows/orchestrator.md b/workflows/orchestrator.md new file mode 100644 index 0000000..7d72137 --- /dev/null +++ b/workflows/orchestrator.md @@ -0,0 +1,74 @@ +# Automation Orchestrator Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Reference contract for an explicitly requested manual maintenance dispatcher. This package provides no recurring scheduler or host adapter. Reading this file does not start a run or authorize writes; a future scheduler requires separate explicit setup. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | automation-orchestrator | +| Display name | Automation Orchestrator | +| Shared guidance | ../automation-guidelines.md | +| Lane index | ../workflow-index.md | + +## Scope + +This workflow inventories repository maintenance routes, selects eligible work across all lanes, dispatches bounded workers, consumes worker outcomes, and records durable GitHub memory. It may create or update validated PRs, GitHub issues, issue comments, PR comments, and review-thread replies when the relevant lane allows that output. + +The orchestrator is dispatcher-only. It must not perform lane-specific implementation, review, triage, docs, digest, marketing, or debt work inline after selecting dispatchable work. + +## Startup + +- Apply `../automation-guidelines.md#startup` and `../automation-guidelines.md#git-and-worktrees` before dispatching work. +- Acquire the single-run lock at the repository-scoped `automation-orchestrator` run lock before GitHub or git writes, following [shared lock ownership and cleanup](../automation-guidelines.md#run-locks). +- Fetch the configured default branch, inspect the dispatcher checkout path, branch, HEAD SHA, and dirty state, and preserve unrelated changes. Apply the shared Git policy's current-checkout default for manual runs and isolated-worktree requirement for separately configured unattended runs. + +## Durable Memory + +- Central discussion title: `Automation Memory - Automation Orchestrator`. +- Central discussion body marker: ``. +- Use central memory to track queue state, active delegated worker IDs, selected work, worker branch/path details, lane cooldowns, blockers, skipped candidates, run summaries, PR links, issue links, and marker/comment follow-up that should not repeat on unchanged state. +- Dedicated lane discussions use `Automation Memory - ` and `` for standalone runs. +- Legacy GitHub Discussion posts are not required migration inputs and may have been deleted. Do not fetch `Codex Automation Memory ...` discussions for backward history. +- Before the first dispatch after the scheduler rename, reconcile live GitHub state into `Automation Memory - Automation Orchestrator`: current open automation PRs/issues, unresolved PR review threads, active blocker/status comments, automation-owned branches, and any current or legacy markers found on live GitHub artifacts. Treat that live-state reconciliation as the migration import boundary so already-visible work is not relaunched. +- After the live-state reconciliation is recorded, use `Automation Memory - Automation Orchestrator` as the durable dedupe source for new work. Treat legacy `codex-*` markers found on live PR comments, review comments, issues, or PR bodies as dedupe inputs during migration, but do not create new legacy markers. + +## Delegation + +- Apply `../automation-guidelines.md#delegation`; it owns worker isolation, host caps, missing-tool behavior, and model/reasoning requirements. +- Per run, perform at most 15 GitHub issue create/update operations across the orchestrator and delegated issue-writing lanes. Exhausting this budget blocks only issue-writing candidates; queue overflow with the candidate key, reason, and next eligible run, then continue evaluating unrelated PR review, docs, digest, Sentry read-only, and code-changing candidates when worker capacity remains. +- Pass each delegated worker a self-contained prompt containing the lane index path, resolved lane Markdown path, assigned work item, relevant live repo/GitHub/Sentry snapshot, central memory excerpt, lane memory excerpt when useful, exact output contract, validation and publishing limits, workspace instructions for the active run mode, and an explicit instruction not to inspect, modify, comment on, or publish unrelated work. +- Record each created worker's ID, agent/tool type, prompt title, assigned work item, lane, initial status, duplicate-prevention key, worker branch, and worktree path in central memory before launching additional duplicate-prone work. + +## Route Coverage + +- Every heartbeat must build one candidate inventory across urgent reliability, PR review/maintenance, merged PR comment follow-up, debt/simplification/decomposition, Code Health Scout cleanup discovery, feature/backlog, routine lane signals, daily digest, completed-worker outcome consumption, and issue/PR follow-up. +- Evaluate every lane workflow indexed in `../workflow-index.md`, even when the first route has work. Mark each route as dispatched, queued, skipped by cooldown, skipped by duplicate active worker, skipped by cap, or blocked by missing evidence/tooling. +- Priority controls dispatch order, not route coverage. Cadence is a backstop for opportunistic scans, not a hard gate: concrete live evidence, queued central-memory work, changed PR heads, new comments, failing checks, fresh Sentry signals, red default-branch/deploy workflows, or explicit maintainer requests route immediately. +- Exhausting a lane-specific budget blocks only that lane; continue evaluating unrelated lanes when scheduler state and worker capacity allow. + +## Run Ordering + +1. Bootstrap: read required guidance, verify GitHub auth, acquire the lock, fetch the configured default branch, inspect dispatcher checkout occupancy, reconcile current automation contracts, read central memory, read the host worker-cap config, inspect live repo health, consume completed worker outcomes, and start the all-route candidate inventory. +2. Urgent reliability: prioritize owned same-repo PRs with merge conflicts, [repairable Django migration graph failures](pr-guardian.md#migration-graph-repair), failed or missing expected validation, actionable unresolved review threads, stale blocker comments, or stale `ready-for-human` label state. Also prioritize red default-branch CI, red deploy workflows, deploy follow-up issues, and fresh P0/P1 Sentry groups when available. +3. Progress mandate: before ending, dispatch or write at least one progress action unless blocked by unsafe scheduler state or exhausted worker capacity. Progress means refining an issue, dispatching bounded tech-debt implementation, or dispatching implementation for a clearly scoped issue that passes the project readiness gate. +4. Debt path: prefer existing `tech-debt` issues and deduplicated Code Health Scout output. Legacy Code Quality issues may be consumed only as already-filed tech-debt context. Open ready-for-review PRs only for small, safe, validated simplifications; create or update issues for complex, unclear, broad, auth, billing, security, infrastructure, migration, deploy, or live-ops-sensitive work. +5. Code Health Scout: dispatch at most one combined scout worker per heartbeat for eligible complexity, Desloppify, and code hygiene discovery. Scout output creates or updates issues only; code cleanup happens later through bounded debt workers. +6. Feature/backlog: before selecting issue implementation, apply the repository's documented readiness/acceptance gate. Do not require a particular project field or status unless its owning guidance defines one; otherwise use explicit user-selected scope and concrete acceptance criteria. +7. Routine lanes: evaluate PR Reviewer, Merged PR Comment Sweep, Daily Bug Scan, Test Gap Detection, Docs Cohesion Keeper, Product Marketing Sync, Sentry Daily Triage, Code Health Scout, and Daily Changes Digest according to their lane contracts and fallback cadences. +8. Dispatch eligible work up to the active worker cap and applicable write budgets. Queue overflow in central memory with a reason and next eligible run. +9. Consume worker outcomes, apply memory updates through one ordered write queue, and stop launching new work if central memory writes fail. +10. End with a concise central-memory run summary covering route inventory, progress action, urgent items, lane decisions, PRs/issues touched, blockers, caps reached, and next queued work. + +## Blockers + +- If GitHub auth, central memory, worker tooling, Sentry access, validation, or live evidence is unavailable, record the blocker in central memory and update the relevant issue or PR marker comment only when useful. +- If an issue or PR already has the same blocker recorded for the same head SHA, Sentry group, issue state, or work item, do not repeat it. +- If work requires a human product decision, production mutation, destructive migration, billing/auth/security judgment, or unbounded refactor, create or refine an issue with concrete next steps instead of editing code. +- Never merge PRs, rebase, force-push, mutate production/live services, or make speculative repo edits. diff --git a/workflows/pr-guardian.md b/workflows/pr-guardian.md new file mode 100644 index 0000000..5d0c398 --- /dev/null +++ b/workflows/pr-guardian.md @@ -0,0 +1,151 @@ +# PR Guardian Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Stabilize open configured-author same-repo PRs through one worker per PR for conflicts, CI, review threads, readiness labels, and high-confidence PR bug sweeps. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | pr-guardian | +| Display name | PR Guardian | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and GitHub scope +- Only operate on open pull requests authored by `CONFIGURED_AUTHOR`. +- Only operate on same-repository pull requests whose head branch also lives in `OWNER/REPO`. Skip forks. +- `pr-guardian` owns active PR stabilization for these PRs: merge-conflict repair, CI follow-up, unresolved review-thread handling, and high-confidence bug scanning inside the PR diff and nearby touched call paths. +- Use `agentics tools --repo-root PATH pr-threads` to enumerate unresolved review threads and to post replies or resolve addressed threads. +- Manage a readiness label only when repository guidance configures one and the current task permits label writes. The compatible GitHub adapter below uses `ready-for-human` as an example; do not create it or impose it on another project. + +### Discussion memory +- Discussion title: `Automation Memory - PR Guardian`. +- Discussion body must begin with ``. +- Track recently handled `@` pairs with the assessed base ref/SHA, active per-PR fix worker/thread IDs, persistent blockers, and marker-comment or review-thread follow-up. Reassess no-op and blocker outcomes when the base or other blocking evidence changes, even if the head is unchanged. +- In standalone runs, a per-PR fix worker/thread may append a PR-scoped outcome comment only for its assigned PR, but it must not rewrite the discussion body or edit another PR's records. +- As per-PR fix workers/threads report outcomes, the dispatcher must apply any needed memory updates through one ordered write queue for each PR changed, skipped for a blocker, left untouched for a stable no-op, or handed off to an active worker/thread. + +### Execution guardrails +- Acquire the single-run lock at the repository-scoped `pr-guardian` run lock before GitHub or git writes, following [shared lock ownership and cleanup](../automation-guidelines.md#run-locks). Use this same path for every Guardian run. +- The single-run lock guards discovery, dispatch, and dispatcher-owned memory writes so overlapping scheduler runs do not create duplicate work. Track active per-PR fix workers/threads through the active memory surface before releasing the lock. +- The main agent is the dispatcher. It must discover eligible PRs, classify them, spawn per-PR workers/threads, consume per-PR outcomes, and serialize memory writes; it must not perform branch-specific fix work in the dispatcher thread. +- For every PR branch classified as needing active work, create exactly one separate worker/thread under the shared delegation rules. Do not combine PR branches, create multiple active workers for the same `@`, or substitute dispatcher-thread helpers; additional helpers stay inside the assigned PR worker. Use `PR Guardian: PR #` as the title when supported, or as the first prompt line, and include the PR number, head/base refs and SHAs, PR URL, memory excerpt, current blocker/status snapshot, and the per-PR workflow rules below. +- Each per-PR fix worker/thread may operate only on its assigned PR branch: fetch that branch, use its assigned branch and the checkout/worktree strategy selected by the user and repository (isolated worktree for enabled unattended runs), write PR-specific marker comments or review-thread replies only for that PR, push only that PR's branch, and clean up only temporary worktrees it created before returning or handing off. +- Never force-push or rebase a PR branch. Only merge the PR base branch into the PR branch when GitHub reports merge conflicts and you are performing a high-confidence conflict-resolution pass in the selected safe workspace. Preserve newer base-branch fixes by default, port the PR's intent onto the current base implementation instead of choosing conflict hunks mechanically, and if the branch still cannot accept a fast-forward push after a validated fix, leave or update one concise top-level PR blocker comment and skip resolving any threads for that PR. +- Keep scope tight. Do not do broad refactors or unrelated cleanup while stabilizing a PR. +- If a fix touches migrations, startup behavior, deploy workflows, or `infrastructure/`, use `$deploy-safety` before editing or pushing. +- If a fix grows into a major change described in the repository standards and the installed `$change-audit` skill, run `$change-audit` before the final validation and push cycle; address only the in-scope findings it surfaces. +- Do not stop merely because one push succeeded. After each push, re-read the PR head, checks, mergeability, and review-thread state once, then keep iterating only while there is actionable work available without waiting on pending CI. If only pending CI remains, hand off the exact pending checks instead of keeping the worker/thread active to poll. + +### Top-level PR comment policy +- Use at most one configured-author top-level PR status comment per PR containing the marker ``. +- Treat existing `` comments as legacy PR Guardian status comments during the migration. Update an existing current or legacy marker comment instead of posting a duplicate, and replace the legacy marker with `` when editing that comment. +- Use the marker comment only for blockers, human follow-up, or lower-confidence bug findings that should not be auto-fixed. +- Before posting a new marker comment, inspect existing top-level PR comments for both current and legacy markers and update the existing marker comment instead of posting a duplicate when possible. +- Before repeating a blocker, compare its recorded head/base snapshot and blocking evidence with the current PR and memory discussion; update obsolete blocker records when that evidence changes. +- If the PR is healthy and there is nothing new to report, do not add a top-level status comment. + +### `ready-for-human` label gate + +Apply this section only when the repository configures this readiness-label contract (or an explicitly mapped equivalent). Otherwise assess readiness without label writes. +- The label means the current PR head is ready for a human to review, not merely that automation made a push or opened the PR. +- Apply `ready-for-human` with `gh pr edit --add-label ready-for-human` only when all of these are true for the current head SHA: + - GitHub reports the PR is not a draft. + - The current head has no unresolved actionable review threads. Use `agentics tools --repo-root PATH pr-threads list-unresolved-threads --repo OWNER/REPO --pr ` to enumerate unresolved threads, classify every unresolved thread by current-head relevance, and treat unclassified, actionable, or ambiguous threads as blocking. Threads already classified and replied as `explanation only` or `already fixed / no longer valid` may remain unresolved and do not block the label. + - GitHub review state has no outstanding requested-changes review for the current head. Prefer `reviewDecision` when available; if it is unavailable or ambiguous, inspect the latest review states before labeling. + - GitHub reports the PR has no merge conflicts with its base branch. + - The current head's GitHub status rollup has reached a settled successful state for the repo's expected PR validation checks, not just branch-protection-required checks. Use `statusCheckRollup` or `gh pr checks` plus the validation checks defined in the owning CI workflow; failed, cancelled, timed-out, queued, in-progress, action-required, or unjustifiably pending or missing expected validation checks do not qualify. Skipped or neutral conclusions qualify only for checks intentionally skipped by CI scope, such as production/cloudflare jobs that are not expected for the PR diff. Treat pending or missing CI checks as intentionally path-filtered only when every changed path is ignored by the workflow's `pull_request.paths-ignore`, no other expected validation check is active or failed, and the worker has run the repo-approved local validation appropriate for the changed files and records that evidence in the outcome. + - The PR description is clean and reviewer-ready: it has a concise summary, reviewer-relevant context, no stale blocker/TODO/TBD/scaffold notes, and no contradiction with the current diff. Follow the repository's metadata policy for whether validation evidence belongs in the PR body. + - If an issue/ticket is discoverable from the PR title, branch name, commit messages, automation memory, or existing issue references, the PR body links it with `Closes #...`, `Fixes #...`, or `References #...` as appropriate. If no ticket is discoverable after checking those sources, do not block the label solely for a missing ticket. + - There is no current `` marker comment, and no legacy `` marker comment, recording an unresolved blocker for the same head SHA. +- Remove `ready-for-human` with `gh pr edit --remove-label ready-for-human` whenever any gate fails, including after a new push, draft state, outstanding requested-changes review, new unresolved actionable or unclassified review thread, failing, unjustifiably pending, or unjustifiably missing expected validation check, merge conflict, stale PR body, or newly discovered missing ticket link. +- Do not post a top-level PR comment just to say the label was added or removed. Mention label changes only in the structured worker outcome and memory update unless a human-facing blocker comment is already warranted. + +### Migration graph repair +- Treat repairable Django migration graph failures on owned same-repo open PRs as urgent PR maintenance. +- When the fix is only stale branch numbering or references, fetch the PR's current base branch, renumber branch-added migrations after that base leaf, and update affected migration `dependencies` and `run_before` references. +- Run the configured migration-graph and backend validation commands from a disposable base-inclusive validation tree before pushing a normal follow-up commit to the assigned PR branch. +- This repair does not authorize merging the base branch, rebasing, or force-pushing. Base-into-head merges remain limited to the explicit merge-conflict path below. +- For destructive schema removal, squashed/replaced chains, intentional pre-base ordering, or other changes needing semantic/deploy-safety review, record the specific blocker in the PR marker comment and active memory instead of auto-editing migrations. + +### Per-run workflow +1. Orchestrator setup: + - Acquire the single-run lock. + - Resolve and read the active memory surface. + - List eligible PRs with `agentics tools --repo-root PATH pr-threads list-open-prs --repo OWNER/REPO --author CONFIGURED_AUTHOR`. +2. Triage each eligible PR branch just enough to classify it as `needs per-PR worker/thread`, `healthy no-op`, `unchanged recorded blocker`, or `skip`. + - Create per-PR workers/threads for branches with merge conflicts, actionable CI failures, actionable unresolved review threads, or high-confidence bug-sweep findings. + - For clean no-op PRs, apply `ready-for-human` only after the readiness label gate passes. For draft, requested-changes, blocker, pending, conflicted, stale-description, or not-yet-fully-checked PRs, remove `ready-for-human` when present. + - Skip clean no-ops and recorded blockers only after confirming their head, base, and relevant evidence remain unchanged. A PR/head SHA with an active per-PR fix worker/thread must not receive a second worker; queue any changed-base reassessment for that worker or its completion. +3. Create exactly one new worker/thread for each PR branch classified as `needs per-PR worker/thread`, bounded by the active worker cap. If there are more branches than available worker slots, queue the remaining branches and create the next worker/thread when a slot frees. +4. Give each per-PR fix worker/thread only the assigned PR number, PR URL, head ref, head SHA, base ref/SHA, an immutable snapshot or PR-specific excerpt of the memory discussion, and these per-PR workflow rules. The worker/thread must not list, triage, or modify other PRs or branches. +5. Inside each per-PR fix worker/thread, fetch only the assigned PR head branch from origin and verify the selected workspace targets it. Preserve the user's manual checkout/worktree preference and unrelated changes. For enabled unattended runs, create an isolated temporary worktree/branch from `origin/` using the repository's branch convention. +6. For the assigned PR, read the current head SHA, base ref and base SHA, mergeability or conflict state against the base branch, diff, combined check status, failing check details, and unresolved review-thread state, then compare that snapshot against the supplied memory discussion snapshot before deciding what to do. +7. Decision order for the current head SHA: + - If GitHub reports the PR is merge-conflicted with its base branch, attempt the merge-conflict-resolution path before CI triage or review-thread handling. + - If CI failed, inspect the failing GitHub Actions jobs or checks, read the relevant logs, and identify the smallest safe fix. Prefer `gh` or the available GitHub app tools for check-run and log inspection. + - If CI is still pending or in progress, treat that as useful context rather than a blocker. Do not wait for CI to complete before inspecting review threads, making safe review-thread fixes, or running the PR bug sweep. + - Inspect unresolved review threads with `agentics tools --repo-root PATH pr-threads list-unresolved-threads --repo OWNER/REPO --pr ` regardless of the current CI state, unless merge conflicts blocked safe work on the PR. +8. When GitHub reports the PR is merge-conflicted with its base branch: + - Fetch `origin/` alongside the head branch and inspect the conflicting files on both sides before editing. + - Attempt exactly one merge of `origin/` into the local PR branch in the selected safe workspace. + - Resolve conflicts intentionally: keep newer base-branch fixes by default, port the PR's behavior onto the current base implementation, and update both sides of any touched caller or callee relationship instead of choosing one side wholesale. + - Use `$change-plan` before cross-surface or deploy-sensitive conflict resolutions. + - Use `$change-tests` when the resolution changes behavior or reopens a real regression surface. + - Use `$change-verify` after the conflict resolution is in place. + - If validation succeeds, create an intentional merge-resolution commit and fast-forward push it back to the existing remote PR branch with `git push origin HEAD:refs/heads/`. + - If the conflict is ambiguous, requires low-confidence product judgment, touches migrations or deploy-sensitive state without a clear safe answer, or validation cannot be made to pass safely, do not push. Update the marker comment with the specific blocker and return a blocked outcome. +9. When CI failed and the failure is actionable in-repo: + - Use `$runtime-probe` when you need to pin down the real failure path. + - Use `$change-plan` before cross-surface or compatibility-sensitive changes. + - Implement the smallest safe fix in the selected safe workspace. + - Use `$change-tests` when the fix needs focused regression coverage around the failing path. + - Use `$change-verify` for lint plus the smallest relevant repo-approved validation command. + - If validation succeeds, create an intentional commit and fast-forward push it back to the existing remote PR branch with `git push origin HEAD:refs/heads/`. + - After the push succeeds, re-read PR state and continue the readiness loop instead of stopping. + - If no safe in-repo CI fix is available and no push was made, record any concrete CI blocker when useful, then continue to review-thread handling and bug scanning for the same head SHA. +10. For any non-conflicted PR head, classify every unresolved review thread as one of: + - `actionable fix`: the feedback is still correct and needs code or test changes. + - `explanation only`: no code change is warranted, but the reviewer should get a rationale. + - `already fixed / no longer valid`: the branch already satisfies the feedback, but do not silently resolve it. +11. For actionable review threads on the same PR: + - Read the surrounding code and relevant diff context before editing. + - Group compatible feedback into the smallest safe fix for the current head SHA. + - Use `$change-plan` before cross-surface or deploy-sensitive fixes. + - Use `$change-tests` when the behavior change has a real regression surface. + - Use `$change-verify` after the change is in place. + - If validation succeeds, create an intentional commit and fast-forward push it back to the existing remote PR branch. + - After the push succeeds, reply to each addressed thread with a concise summary of the fix plus the validation command, then resolve those addressed threads with `agentics tools --repo-root PATH pr-threads reply-thread --thread-id --body-file --resolve`. + - Inspect the JSON output from each `reply-thread --resolve` call. If it includes `resolve_error`, treat the reply as already posted, do not rerun the same command for that thread in the same run, leave the thread open, and update the marker comment with the unresolved follow-up. +12. For `explanation only` and `already fixed / no longer valid` threads: + - Reply with a concise rationale using `agentics tools --repo-root PATH pr-threads reply-thread --thread-id --body-file `. + - Do not resolve those threads. +13. When there are no remaining actionable review-thread fixes for the current head SHA, run a PR bug sweep even if CI is pending or failed: + - Use `$pr-review` as a read-only pass when you want a structured risk check on the current head. + - Inspect the current diff, touched call paths, nearby tests, and obvious edge cases for likely regressions or missing tests. + - Prefer high-confidence findings only. Do not invent bugs. + - If you find a high-confidence bug that can be fixed safely, use `$change-plan`, make the smallest safe fix, use `$change-tests` when warranted, run `$change-verify`, create an intentional commit, and fast-forward push the fix. + - If a finding is plausible but not high-confidence or cannot be validated locally, update the marker comment with the specific concern, concrete evidence, and suggested follow-up; do not push speculative changes. +14. Readiness loop: + - After each push, review-thread reply, marker-comment update, rerun, or meaningful external-state change, re-read the PR head SHA, base ref/SHA, draft state, mergeability, review decision or latest review state, status rollup and expected PR validation checks, unresolved review threads, and current marker comment. + - Continue iterating through conflict repair, CI triage for settled failures, review-thread handling, and bug sweep until GitHub shows the PR is not draft, mergeable, expected PR validation checks are settled and successful or explicitly path-filtered with local validation evidence, no requested-changes review is outstanding for the current head, there are no actionable or unclassified unresolved review threads, and the high-confidence bug sweep is clean, or until only pending CI remains. + - If checks are pending after a fresh push, record the exact pending checks in the outcome and do not mark the PR merge-ready. Do not wait, re-check, or keep the per-PR worker/thread active solely for CI polling unless the operator explicitly asked for monitoring. + - Use `$pre-pr-gate` or the closest applicable repo-approved validation before declaring a changed PR ready to merge. + - Apply `ready-for-human` only after the full readiness label gate passes for the final current head. If any gate fails, remove the label when present and include the failing gate in the outcome. +15. Before returning or handing off, each per-PR fix worker/thread must clean up any temporary worktree it created and return a structured outcome containing: PR number, final head SHA, assessed base ref/SHA, created worker/thread ID if known, action taken, whether pushes happened, marker-comment changes, review-thread replies or resolutions, validation commands and results, `ready-for-human` label action, final merge-readiness status, blocker and its evidence if any, cleanup status, and the exact memory-discussion update including that head/base/evidence snapshot. If live refs changed after assessment, report the stale snapshot and queue reassessment rather than recording the new refs as validated. +16. The dispatcher must consume per-PR worker/thread outcomes as they finish and apply each returned memory update through the ordered discussion write queue. If a memory update fails, stop launching additional workers/threads, do not fall back to local memory, and include the failure in the run outcome. + +### Blocked cases +- If a PR branch no longer exists, cannot be fetched, or cannot be pushed safely, update the marker comment and skip it. +- If CI fails due to external infrastructure, flaky services, or missing credentials and there is no safe in-repo fix, update the marker comment with the concrete blocker when useful, but continue with review-thread handling and bug scanning for the same head SHA unless another blocker prevents safe work. +- If merge conflicts require low-confidence product judgment or cannot be resolved safely against the current base branch, update the marker comment with the conflicting files and concrete blocker, then skip it. +- If local validation fails and a safe fix is clear, continue iterating locally. If validation cannot be made to pass safely, do not push; update the marker comment with the failing command and blocked scope, then return a blocked outcome. +- If unresolved review-thread data is stale or ambiguous, leave a concise reply on the affected thread explaining what blocked automation and leave it open. +- Do not repeat a thread reply or marker comment while the recorded head/base snapshot and relevant blocking evidence remain unchanged. Reassess changed evidence under the top-level comment policy before updating the existing comment. diff --git a/workflows/pr-reviewer.md b/workflows/pr-reviewer.md new file mode 100644 index 0000000..ba93aec --- /dev/null +++ b/workflows/pr-reviewer.md @@ -0,0 +1,91 @@ +# PR Reviewer Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Run deep read-only PR reviews with one worker per PR/head SHA and deduplicated review comments. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | pr-reviewer | +| Display name | PR Reviewer | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and review scope +- Review every open pull request in the repository, including fork PRs. This automation is read-only with respect to code and branches; it may write GitHub review comments only. +- `pr-reviewer` owns deep review and review-comment hygiene. `pr-guardian` owns active fixes, pushes, CI repair, merge-conflict repair, and resolving addressed review threads after fixes. +- Do not edit files, commit, push, merge, rebase, force-push, create branches intended for publication, or open pull requests from this automation. + +### Discussion memory +- Discussion title: `Automation Memory - PR Reviewer`. +- Discussion body must begin with ``. +- Track reviewed `@` pairs with the reviewed base ref/SHA, active per-PR review worker/thread IDs, automation-owned finding keys and comment URLs, no-finding outcomes, and any skipped or blocked PRs. +- In standalone runs, a per-PR review worker/thread may append a PR-scoped outcome comment only for its assigned PR, but it must not rewrite the discussion body or edit another PR's records. + +### Execution guardrails +- Acquire the single-run lock at the repository-scoped `pr-reviewer` run lock before GitHub writes, following [shared lock ownership and cleanup](../automation-guidelines.md#run-locks). +- The main agent is the dispatcher. It must discover open PRs, create exactly one per-PR review worker/thread per PR/head SHA that needs review, consume outcomes, and serialize memory discussion writes. It must not do PR-specific review work in the dispatcher thread. +- Create each per-PR review worker/thread under the shared delegation rules; dispatcher-thread tasks or helpers do not satisfy the boundary, and additional helpers stay inside the assigned PR worker. Use `PR Reviewer: PR #` as the title when supported, or as the first prompt line, and include the PR number and URL, author, head repository/ref/SHA, base ref/SHA, review-memory excerpt, existing automation finding keys, and the per-PR workflow rules below. +- Create review workers/threads for every open PR, bounded by the active worker cap. Queue remaining PRs and launch the next review worker/thread when a slot frees. Do not combine multiple PRs in one worker/thread. +- If a PR/head SHA already has an active review worker/thread recorded in the memory discussion, do not create a duplicate worker/thread. Re-read or resume that worker/thread when tooling supports it, or record that it is still active. +- Prefer existing CI evidence and code inspection over broad local test runs. + +### Required context for each PR +- Read the current PR metadata, title/body, base/head refs and SHAs, author, draft state, changed files, review decision, merge state, checks, and CI summary. +- Read all current PR conversation surfaces before reviewing: top-level issue comments, submitted reviews, review bodies, review comments, resolved and unresolved review threads with every thread comment, existing requested changes, existing approvals, existing `` +- Treat existing `` comments as automation-owned legacy reviewer findings during the migration. Use them in the dedupe map, update or retire them when the finding persists or is fixed, and emit only the current `automation-pr-reviewer` marker on new comments. +- Derive `` from the normalized file path, affected symbol or route, risk category, and short finding slug. Keep the same key when a finding persists across a new head SHA. +- Prefer precise inline review comments on the current diff for actionable findings. Use a single top-level marker comment only when the finding cannot be anchored to a current diff line. +- If an existing automation-owned finding still applies, update the existing comment when GitHub permits it; otherwise reply with the revised evidence and do not create a duplicate. +- If an existing automation-owned finding no longer applies, reply once that it appears fixed on the current head and resolve only automation-owned review threads when the API permits safe resolution. Do not resolve human-authored review threads. +- If a human reviewer already made the same point, do not duplicate it. Add a concise reply only when there is important new evidence or a sharper reproduction path. +- If a PR has no findings and no stale automation-owned comments to retire, leave the PR quiet and record the clean review in the memory discussion. +- Never post comments that expose secrets, tokens, private customer data, or unnecessary raw logs. + +### Per-run workflow +1. Dispatcher setup: + - Acquire the single-run lock. + - Resolve and read the active memory surface. + - Inventory all open PRs with `gh api --paginate --method GET 'repos/OWNER/REPO/pulls?state=open&per_page=100' --jq '.[] | {number, url: .html_url, head_sha: .head.sha}'`. Complete every page before dispatch; if discovery fails, record the blocker instead of treating partial output as the full queue. + - Read each PR's current metadata and conversation state required above before deciding whether it needs review. +2. For every open PR, decide whether the current `@` needs a new review worker/thread: + - Needs review when the head SHA is not recorded as cleanly reviewed, the reviewed base ref/SHA is missing or changed, existing automation findings may need updates, the PR has new human comments/reviews since the last reviewer run, or the prior review worker/thread is missing/blocked. + - Skip duplicate worker/thread creation when the same head SHA already has an active per-PR review worker/thread in memory. +3. Create exactly one separate worker/thread for each PR needing review, queueing above the active worker cap. Do not begin PR-specific review work until that PR's worker/thread exists. +4. Each per-PR review worker/thread must: + - Fetch the PR head and base refs read-only, using a temporary worktree when full-file inspection materially improves confidence. + - Gather the required PR comment/review/thread context before starting the review. + - Map changed files to touched runtime, test, docs, deploy, and frontend surfaces. + - Inspect real call paths and relevant tests, not just the diff hunk. + - Produce a findings-first review report with exact file/line references, severity, confidence, impact, evidence, and duplicate-feedback notes. + - Immediately before any review-comment write, re-read the PR state, head SHA, base ref/SHA, and relevant feedback for dedupe. Post or update only comments that meet the high-confidence threshold when the PR remains open and that snapshot still matches the reviewed diff. If the head or base changed, stop posting and return `stale-head` or `stale-base` with the old and new snapshot so the dispatcher can queue a fresh review. + - Avoid code edits and branch mutations entirely. +5. Before returning, re-read the head SHA and base ref/SHA again. If they changed after any comments were posted, report those writes and the stale snapshot; do not record the new snapshot as reviewed or clean. +6. Each per-PR worker/thread returns a structured outcome: PR number, head SHA and base ref/SHA reviewed, worker/thread ID if known, files and surfaces inspected, helper agents used, comments read, findings posted or updated, stale comments retired, no-finding result when applicable, blockers, and the exact memory-discussion update requested. +7. The dispatcher consumes per-PR outcomes as they finish and applies each returned memory update through an ordered discussion write queue. If a memory update fails, stop launching additional review workers/threads and include the failure in the run outcome. + +### Blocked cases +- If a PR cannot be fetched or its diff cannot be read, record the blocker in memory and add or update one top-level `` PR comment only when the blocker affects review quality and is not already recorded for the same head SHA. +- If GitHub comment APIs fail after a review found issues, record the unposted findings and API error in memory without retry-spamming the PR. +- If a finding depends on an expensive or unavailable validation path, either gather more code evidence or downgrade it to residual risk in memory; do not post low-confidence review noise. diff --git a/workflows/product-marketing-sync.md b/workflows/product-marketing-sync.md new file mode 100644 index 0000000..243ced1 --- /dev/null +++ b/workflows/product-marketing-sync.md @@ -0,0 +1,25 @@ +# Product marketing sync workflow + +Read [repository context](../repository-context.md) and [shared maintenance rules](../automation-guidelines.md). Resolve the product name, allowed marketing paths/tests, copy owner, design/product docs, excluded implementation areas, validation and performance commands, and legacy workflow markers from the target repository guidance. If the allowed path set is unresolved, stop before editing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + +## Scope and evidence + +Keep public marketing copy, navigation, metadata, sitemap, FAQ, pricing/plan language, and canonical redirects aligned with shipped product truth. Inspect actual routes, user-visible labels, supported features, and product models read-only before choosing a claim. Do not advertise planned, partially implemented, manually enabled, or unsupported capabilities as generally available. + +Use one bounded worker per drift group and inspect open PRs/issues and active workers first. Limit writes to configured marketing surfaces and their coupled tests. Do not change product implementation, agent capabilities, schedulers, dependencies, unrelated apps, or provider configuration to make marketing claims true. + +## Memory and output + +Use `Automation Memory - Product Marketing Sync` and `` for standalone memory unless the project guidance preserves an existing title/marker. Orchestrated runs return state to central memory. Search current and configured legacy issue/PR markers before creating another item; new issue keys use ``. + +## Workflow + +1. Compare canonical marketing copy, public navigation, metadata/social cards, sitemaps, canonical/redirect behavior, and narrow/mobile presentation with verified product behavior and app-local design. +2. Select at most one high-confidence drift group. Create or refine a deduplicated issue when product intent or shipped behavior is uncertain; do not guess pricing, legal, security, or product promises. +3. Within existing authorization, make the smallest complete copy/navigation/SEO change and meaningful tests. Preserve established branding, accessibility, canonical ownership, and product vocabulary. +4. Run configured targeted checks, route/navigation integration tests when relevant, and advisory performance checks without treating advisory output as a new hard gate. +5. Capture relevant before/after previews through the project adapter. Publish native attachments only when authorized, then separately refresh PR metadata with published URLs. Keep local captures ignored. +6. Publish a validated bounded PR only within current write authority, or return the local result/deduplicated issue and blocker. Report claims verified, exact validation, remaining unknowns, and outcome key. Do not monitor pending CI unless requested. diff --git a/workflows/sentry-daily-triage.md b/workflows/sentry-daily-triage.md new file mode 100644 index 0000000..7992d1e --- /dev/null +++ b/workflows/sentry-daily-triage.md @@ -0,0 +1,117 @@ +# Sentry Daily Triage Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Group recent Sentry issues into actionable GitHub issues without mutating Sentry or production state. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | sentry-daily-triage | +| Display name | Sentry Daily Triage | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and external systems +- Use the Sentry MCP tools directly for all Sentry reads. Prefer `search_issues` for grouped issue discovery, `get_sentry_resource` for issue detail, and `search_issue_events` or `get_issue_tag_values` only when extra scope evidence is needed. Do not use guessed data, cached results, plugin links, or installed Sentry fallbacks for this automation. +- Query every entry in the configured Sentry project list across all environments for the trailing 24 hours. Treat the project slug as part of every candidate's identity and evidence. +- Do not invoke installed `pr-*`, `change-*`, or `runtime-*` skills for this run; this automation performs Sentry reads and GitHub issue triage only. + +### Configured Sentry projects + +Load the complete project list from the target repository guidance; an empty or unresolved list blocks this lane. + +This configured list is the required full-scan set. A run must query every configured project, including projects that return zero candidates, before it can make any GitHub or memory write. A partial project scan is a blocker, not a successful no-op. + +### Discussion memory +- Discussion title: `Automation Memory - Sentry Daily Triage`. +- Discussion body must begin with ``. +- Use it to track the last completed scan window, recent non-actionable skip reasons worth remembering, and the latest created or updated GitHub issue links. +- Record the scan window, summary counts, and any issue links. + +### Execution guardrails +- Verify Sentry MCP access before any Sentry work with a lightweight read against every configured project. This unattended run must never ask for Sentry credentials interactively or attempt any interactive auth flow. If any configured project is unavailable or unauthorized, stop immediately, name the affected project in a blocker summary, and make no GitHub or memory writes. +- Never create or update pull requests for this automation. This run only creates or updates GitHub issues plus the active memory state. +- Never close GitHub issues automatically. +- Never include auth tokens, PII, IP addresses, email addresses, session identifiers, or full raw stack traces in issue bodies or comments. +- Keep labels minimal. Use GitHub Project fields, issue body metadata, or the hidden automation state for priority and similar classification. + +### Per-run workflow +1. Read the active memory surface, then iterate the configured Sentry project list and fetch unresolved issues from the last 24 hours across all environments for each project with the Sentry MCP `search_issues` tool. Record a scan result for projects with zero matches. Before grouping or writing anything, verify that the queried-project set exactly matches the configured-project set; otherwise return a blocker without writes. +2. For each candidate issue, enrich with detail and recent events only when needed to extract: + - project slug + - error type or primary exception signature + - title + - culprit + - route or transaction + - release + - count + - first seen + - last seen + - top tags or dimensions that help explain scope +3. Normalize a grouping tuple of `category + error signature + culprit_or_route`: + - lowercase the values + - trim whitespace + - collapse repeated whitespace and punctuation into single hyphens + - remove obviously transient numeric-only ids, UUID-like fragments, and hex-only request identifiers when they would fragment the same root cause + - if both culprit and route exist, prefer the route when it is a stable user-facing path; otherwise use culprit +4. Categorize each issue into exactly one bucket: + - `background-job`: worker, cron, queue, Celery, async task, or management command failures + - `database-cache`: database, ORM, Redis, cache, lock, migration, or connection-pool failures + - `auth-permissions`: login, session, CSRF, auth, permission, 401, or 403 failures + - `integration`: third-party API, webhook, payment provider, email provider, or external service failures + - `performance`: timeout, latency, memory-pressure, or degraded-performance incidents without a clearer bucket above + - `client-error`: browser or frontend exceptions where the primary failure is client-side + - `server-error`: server-side request failures or unhandled backend exceptions on user-facing paths + - `unknown`: anything that does not fit cleanly +5. Form groups only when project slug, category, normalized error signature, and normalized culprit_or_route all align. Keep the group keys and counts project-scoped even when separate backend and frontend groups appear to share a root cause. Those groups may update the same existing GitHub issue only when their category, normalized signature, normalized route or culprit, Sentry short ids, and issue-body evidence genuinely align. +6. Mark a group as non-actionable only when the evidence clearly indicates one of: + - bot or crawler noise + - browser-extension pollution + - expected 4xx misuse or obviously invalid probing traffic + - a single occurrence with too little context to act on safely +7. Prioritize every actionable group: + - `p0`: prod outage, login-blocking, payment-blocking, data-loss-looking behavior, or `count >= 50` on a user-facing request path + - `p1`: any other user-facing prod exception, or `count >= 10` + - `p2`: all remaining actionable groups +8. Build a deterministic project-scoped group key as `|||`. +9. Search GitHub issues in `OWNER/REPO` across all states for an existing match in this order: + - the hidden marker `` + - only for the explicitly configured legacy-marker project, the legacy backend-only marker ``; never use this fallback for another project + - referenced Sentry short ids from the grouped issues + - a close title and category match supported by the issue body evidence +10. If a match exists, preserve every existing triage marker in the issue body and add the current project-scoped marker when it is absent; this promotes legacy matches and gives each aligned project-scoped group a stable future lookup without discarding history. Then add one concise update comment containing the participating project slugs, fresh 24-hour count, last seen timestamp, release spread, affected Sentry short ids, and any changed priority signal. If the matched issue is closed, prefer commenting on that existing issue rather than creating a duplicate; only reopen it when explicitly desired. +11. If no match exists in any issue state, create one new GitHub issue with: + - title: `[Sentry][category] concise root-cause summary` + - labels: `bug` and `automation` + - body records the priority as `Priority: p0`, `Priority: p1`, or `Priority: p2`; set the GitHub Project `Priority` field when that surface is available + - body starting with `` + - visible sections using `### Summary`, `### Impact`, `### Sentry Evidence`, `### Representative Events`, `### Recommended Next Step`, and `### References` + - `
...` blocks for per-issue or per-event detail +12. When writing the issue body or update comment: + - summarize participating project slugs, grouped counts, first seen, last seen, release spread, and affected routes or culprits + - include Sentry short ids and direct Sentry links when available + - redact or paraphrase sensitive payloads and stack details + - keep the recommended next step concrete and minimal +13. Compare each project's previously recorded lane-memory groups with the current scan. Count a group as `stale/fixed` for that project only when the referenced Sentry group is resolved or had no activity in the current window, and record which condition applies. This is reporting evidence only; never close the matching GitHub issue automatically. +14. End the run with a concise overall summary plus one row per configured project. The overall summary states: + - total Sentry issues scanned + - actionable groups formed + - GitHub issues created + - GitHub issues updated + - groups skipped, with the skip reason + + Each project row records scanned issues, actionable groups, GitHub issues created, GitHub issues updated, groups skipped, and stale/fixed groups. If aligned project-scoped groups update the same GitHub issue, count one update in the overall GitHub action totals and one in each participating project's row; actionable-group totals remain project-scoped. +15. Record the scan window, overall and per-project summary counts, issue links, and any blocker or no-op note through the active memory surface. + +### Quality bar +- Prefer merging noisy duplicates into a single GitHub issue when the root cause is genuinely the same. +- Do not merge unrelated failures just because the titles look similar. +- If matching an existing GitHub issue is ambiguous, prefer updating only when the hidden marker or Sentry short ids confirm the relationship; otherwise create a new issue. +- Keep GitHub output concise, readable, and aligned with the repository reporting format. diff --git a/workflows/test-gap-detection.md b/workflows/test-gap-detection.md new file mode 100644 index 0000000..b98c5b5 --- /dev/null +++ b/workflows/test-gap-detection.md @@ -0,0 +1,63 @@ +# Test Gap Detection Workflow + +Read [repository context](../repository-context.md) and the project guidance before this lane. Commands, authors, default branch, timezone, integrations, path scopes, labels, discussion categories, readiness rules, and legacy marker aliases come from that guidance. This playbook creates no recurring job and grants no Git, GitHub, or live-system write permission by itself. Stop dependent actions when required configuration is missing. + + +Memory backends, readiness labels, PR-body policy, budgets, and output destinations follow repository guidance. Discussion names and marker schemas below are GitHub-adapter defaults only when that adapter is selected; retain local legacy aliases. A capability described as permitted by this lane still requires current user/host authorization. + + +TL;DR: Add focused regression coverage for concrete missing-test opportunities outside active PR stabilization. + +## Metadata + +| Field | Value | +| --- | --- | +| Workflow ID | test-gap-detection | +| Display name | Test Gap Detection | +| Shared guidance | ../automation-guidelines.md | + +## Lane Contract + +### Repository and ownership scope +- Focus this automation on landed changes, recent branch work not already represented by an open configured-author PR, or concrete missing-test regressions discovered outside active PR stabilization. +- Before choosing work, inspect open same-repository pull requests authored by `CONFIGURED_AUTHOR` and exclude branch heads or commit ranges already under active PR review. +- `pr-guardian` owns test-gap follow-up inside open configured-author same-repository PRs. Do not modify, comment on, or duplicate work already covered by that PR loop. + +### Discussion memory +- Discussion title: `Automation Memory - Test Gap Detection`. +- Discussion body must begin with ``. +- Record the last reviewed commit boundary, selected test gap or no-op result, blocker state, and any PR link. + +### Execution guardrails +- Keep scope tight to the changed area. Avoid broad refactors, speculative coverage campaigns, or unrelated cleanup. +- Prefer small, reliable tests that fail before and pass after. Favor unit tests for tight logic and integration tests only when the user path requires them. +- Keep edits within the assigned branch and scope. Follow the user/repository checkout strategy; enabled unattended code-changing runs require an isolated worktree. + +### Per-run workflow +1. Read the active memory surface, inspect recent landed changes since the last reviewed boundary captured there or the trailing 24 hours when no usable boundary exists, and exclude work already represented by open configured-author PRs. +2. Select up to 5 concrete missing-test opportunities with clear regression surfaces. +3. Partition the selected opportunities into work items: group related gaps only when they cover the same changed area or regression surface, and keep distinctly different gaps in separate work items. +4. For each work item, use the assigned safe workspace/branch under the user's and repository's strategy. Enabled unattended runs use isolated worktrees; manually requested current-checkout work preserves unrelated changes. +5. If missing coverage depends on an unclear failure path, use `$runtime-probe` before broadening into any code-plus-test change. +6. Use `$change-tests` to keep the test work tightly scoped to the selected area. +7. Use `$change-plan` only when a minimal runtime change is required alongside the test to make the regression concrete or correct. +8. Implement the smallest coherent test-only or code-plus-test change in the assigned workspace/branch. +9. If the work crosses the major-change threshold described in the repository standards and the installed `$change-audit` skill, run `$change-audit` before publishing. If that audit surfaces canonical doc drift tied to the change, run `$change-docs` in the same branch. +10. Use `$change-verify` before publishing each test-only or code-plus-test change. +11. Produce `$pr-handoff`, then run `$pr-metadata` to finalize the PR title, body, and labels for each branch. +12. If validation succeeds, open a ready-for-review PR with `$github:yeet` using the `$pr-metadata` title, body, and labels. Use the configured title prefix and existing labels; `Test Gap Detection:` and `automation` are compatible adapter defaults, not new label-creation authority. +13. Record the reviewed boundary, every selected opportunity, grouped work item, outcome, and any PR links through the active memory surface. + +### Blocked or unclear cases +- If no concrete missing-test candidate exists, stop without error. +- If overlap with an open PR, missing regression evidence, or unclear behavior prevents a safe change, skip that candidate. +- If validation fails for one work item, do not publish that PR; continue only if the failure is isolated and does not invalidate the remaining selected work. +- Exclude tool-generated or unrelated files from any PR. +- Record a concise no-op or blocker through the active memory surface when the run ends without a PR. + +### Execution norms +- Fix up to 5 test-gap items per run, with at most 5 PRs per run. +- Open one PR per distinct missing-test opportunity or coherent related group; do not combine unrelated test gaps into the same PR. +- Stop before the cap when evidence, validation confidence, PR overlap, or time budget makes additional opportunities unsafe. +- Prefer no-op over speculative or over-broad test additions. +- Do not open a PR with partial work, guessed expectations, or unvalidated scope.