From 564d0a610420f1bb7ef46a5dbb01059360353d8c Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:16:17 -0500 Subject: [PATCH 1/5] Add glab CLI skill and automated MR review to act-gitlab-ci Add the `glab` command-line interface skill as a reference for all GitLab operations, and introduce automated merge-request review as an engine-agnostic capability across four surfaces: GitLab CI pipelines, git hooks, in-session review, and GitHub Copilot native code review. The review system is built around a stable contract (rubric plus findings JSON schema) with swappable engines (docker-agent, claude, codex, copilot). Deterministic delivery scripts handle all side effects: the engine reviews the diff and produces findings; the wrapper validates and posts to GitLab. Token modes (`inline`, `summary`) post discussions and notes; `log` mode writes to artifacts and the job log when no token is available. Stale reviews resolve on re-push; pipeline retries on the same commit are no-ops. The diff is always untrusted input, so the engine runs with no GitLab credentials and read-only toolsets. This is recorded in ADR 0005. The plugin version moves from 0.2.0 to 0.3.0 across all marketplace entries and manifests. --- .agents/skills/glab/SKILL.md | 253 ++++++ .claude-plugin/marketplace.json | 13 +- .github/plugin/marketplace.json | 2 +- README.md | 2 +- .../0005-mr-review-engine-agnostic.md | 93 +++ docs/decisions/README.md | 1 + .../act-gitlab-ci/.claude-plugin/plugin.json | 8 +- .../act-gitlab-ci/.codex-plugin/plugin.json | 6 +- plugins/act-gitlab-ci/README.md | 77 +- plugins/act-gitlab-ci/commands/review-mr.md | 57 ++ .../act-gitlab-ci/commands/setup-mr-review.md | 82 ++ plugins/act-gitlab-ci/plugin.json | 4 +- plugins/act-gitlab-ci/scripts/ai-review.sh | 174 ++++ .../act-gitlab-ci/scripts/post-mr-review.ts | 757 ++++++++++++++++++ .../mr-review/fixtures/claude-output.json | 9 + .../mr-review/fixtures/discussions-stale.json | 20 + .../tests/mr-review/fixtures/mr-changes.json | 24 + .../mr-review/fixtures/notes-with-marker.json | 14 + .../fixtures/notes-without-marker.json | 6 + .../tests/mr-review/fixtures/stub-engine.sh | 5 + .../fixtures/transcript-malformed.ndjson | 4 + .../mr-review/fixtures/transcript.ndjson | 6 + .../scripts/tests/mr-review/run-tests.sh | 206 +++++ .../scripts/tests/mr-review/unit.test.ts | 251 ++++++ .../skills/ci-auth-providers/SKILL.md | 2 +- .../examples/bedrock-oidc.yml | 2 +- .../skills/mr-review-agent/SKILL.md | 103 +++ .../copilot-code-review.instructions.md | 33 + .../examples/git-hook-pre-push.sh | 39 + .../examples/mr-review-job.yml | 70 ++ .../examples/review-agent.yaml | 46 ++ .../references/copilot-code-review.md | 47 ++ .../references/docker-agent-config.md | 92 +++ .../references/gitlab-discussions-api.md | 85 ++ .../references/review-modes.md | 83 ++ .../references/review-rubric.md | 83 ++ .../act-gitlab-ci/skills/review-mr/SKILL.md | 12 + .../skills/setup-mr-review/SKILL.md | 12 + 38 files changed, 2750 insertions(+), 33 deletions(-) create mode 100644 .agents/skills/glab/SKILL.md create mode 100644 docs/decisions/0005-mr-review-engine-agnostic.md create mode 100644 plugins/act-gitlab-ci/commands/review-mr.md create mode 100644 plugins/act-gitlab-ci/commands/setup-mr-review.md create mode 100644 plugins/act-gitlab-ci/scripts/ai-review.sh create mode 100644 plugins/act-gitlab-ci/scripts/post-mr-review.ts create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/claude-output.json create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes.json create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-without-marker.json create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-engine.sh create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript.ndjson create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/examples/copilot-code-review.instructions.md create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/examples/git-hook-pre-push.sh create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/examples/review-agent.yaml create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md create mode 100644 plugins/act-gitlab-ci/skills/mr-review-agent/references/review-rubric.md create mode 100644 plugins/act-gitlab-ci/skills/review-mr/SKILL.md create mode 100644 plugins/act-gitlab-ci/skills/setup-mr-review/SKILL.md diff --git a/.agents/skills/glab/SKILL.md b/.agents/skills/glab/SKILL.md new file mode 100644 index 0000000..2994560 --- /dev/null +++ b/.agents/skills/glab/SKILL.md @@ -0,0 +1,253 @@ +--- +name: glab +description: > + GitLab CLI (glab) for working with GitLab from the command line. Read this + skill before running any `glab` or GitLab API command — it applies to every + GitLab operation, whether reading or writing (for example merge requests, + issues, work items, discussions and threaded replies, comments, CI/CD + pipelines, releases, packages, members, and project settings). Whenever a + task touches GitLab in any way, consult this skill first so you use the + correct, safe command on the first try. Prefer glab over raw API calls for + all GitLab operations. +--- + +# GitLab CLI (glab) + +`glab` is pre-configured and available in your environment. Use it for all +GitLab operations. Run `glab --help` for detailed flag information. + +## Quick reference + +```shell +# Issues +glab issue view +glab issue list --label "bug,priority::1" +glab issue create --title "title" --description "$(cat /tmp/desc.md)" +glab issue note -m "comment text" + +# Merge requests +glab mr create --push --title "fix: title" --description "$(cat /tmp/desc.md)" +glab mr view +glab mr list --assignee +glab mr update --description "$(cat /tmp/desc.md)" +glab mr note create -m "comment text" + +# CI/CD +glab ci status +glab ci status --output json +glab ci list +glab ci get --merge-request --with-job-details +glab ci get --pipeline-id --output json +glab ci retry +glab api projects/:id/jobs//trace + +# Machine-readable output +glab mr list --output json | jq '.[].title' +``` + +**Templates:** Check `.gitlab/merge_request_templates/` and +`.gitlab/issue_templates/` for project-specific templates. + +**References:** Always use full URLs in note/comment bodies (e.g. +`https://gitlab.com/org/project/-/issues/123`) instead of short references +(`#123`, `!456`). This applies to issues, merge requests, epics, and so on. +Short refs resolve against project context and render as literal text on +group-level items (epics, group work items); full URLs expand everywhere. + +## Comments and discussions + +Use the `mr note` subcommands (`create`, `resolve`, `reopen`); flags on the +root `glab mr note` command are deprecated. + +### Short, inline bodies — pass `-m` + +```shell +glab issue note -m "comment text" +glab mr note create -m "comment text" +glab incident note -m "comment text" + +# Cross-project +glab mr note create -m "..." --repo group/project +``` + +### Long or Markdown bodies — pipe to stdin (preferred for MR notes) + +`glab mr note create` reads the body from stdin when its input is a pipe. +This avoids shell-quoting pitfalls (backticks, `$`, backslashes) and is the +safest pattern for non-interactive use. + +```shell +# From a file +glab mr note create < /tmp/body.md + +# Inline literal multi-line body — quoted heredoc, no shell expansion inside +glab mr note create << 'EOF' +Your **markdown** comment. +Code blocks and `inline code`, $variables, and \backslashes are all literal. +EOF +``` + +`glab issue note` and `glab incident note` do **not** read stdin. For long +bodies on those commands, use `glab api` with `-F body=@file` (see +[Content-type guidance](#content-type-guidance)) or inline a quoted heredoc +into `-m`: + +```shell +glab issue note -m "$(cat << 'EOF' +Your **markdown** comment. +Code blocks and `inline code` are safe. +EOF +)" +``` + +For descriptions on `glab issue create` / `glab mr create` / `glab mr update`, +inline a quoted heredoc into `--description`, or for very large or reusable +bodies write to a file and use `--description "$(cat /tmp/desc.md)"`. + +### Threaded replies on merge requests + +`glab mr note create` supports `--reply ` for replying inside +an MR thread. The value can be the full discussion ID or a unique prefix of +at least 8 characters. + +Diff comments accept a single line (`--line 42`), a range (`--line 10:15`), +a removed line (`--old-line 7`), or no line for a file-level comment. + +```shell +glab mr note create --reply -m "I agree!" +glab mr note create --file main.go --line 42 -m "Needs refactoring" +glab mr note create --file main.go --line 10:15 -m "Extract this block" +glab mr note create --file main.go --old-line 7 -m "Why was this removed?" +glab mr note create --file main.go -m "General comment on this file" +glab mr note create -m "LGTM" --unique # idempotent: skip if same body exists +``` + +`glab mr note resolve` / `reopen` take the MR identifier followed by the +discussion identifier. The identifier can be a discussion ID (full 40-char +hex or 8+ char prefix) or a note ID (integer; the parent discussion is +looked up automatically): + +```shell +glab mr note resolve +glab mr note resolve # integer note ID also works +glab mr note reopen +``` + +### Threaded replies on issues, incidents, and work items + +The CLI does not wrap threaded replies for these, so you fall back to +`glab api`. **For any non-trivial body, write it to a file and post the file** +rather than inlining rich Markdown — inlined backticks, `$`, newlines, and a +leading `@` all break (see [Content-type guidance](#content-type-guidance)): + +```shell +# Discover the discussion ID +glab api projects/:id/issues//discussions \ + | jq '.[] | {id, body: .notes[0].body}' + +# Build the body in a file, then post it with -F body=@file +cat > /tmp/reply.md << 'EOF' +@user — here's the result, with `code`, a $variable, and an emoji ✅. +EOF +glab api projects/:id/issues//discussions//notes \ + -F body=@/tmp/reply.md +``` + +For a short, plain reply you can still inline it with `-f body="reply text"`. + +## API calls + +`glab api` auto-prepends `/api/v4/`. Use relative paths: + +```shell +glab api user # NOT /api/v4/user +glab api projects/:id/merge_requests +glab api projects/:id/issues | jq '.[0]' +``` + +When using `-f` for PUT/POST, pass simple `key=value` pairs. Array bracket +syntax like `ids[]=1` is not supported: + +```shell +glab api projects/:id/merge_requests/:iid -X PUT -f "assignee_id=1" +``` + +### Content-type guidance + +```shell +# -f / --raw-field — literal string value +glab api projects/:id/issues/:iid/notes -f body="comment text" + +# -F / --field — reads @file as a string. The leading @ means "read this +# file", so only pass a real path here. A literal body that starts with @ +# (e.g. "@user thanks") must NOT go through -F — it would be read as a +# filename. Use -f for literal inline text, or write the body to a file and +# point -F at the file (recommended for rich/markdown bodies). +glab api projects/:id/issues/:iid/notes -F body=@/tmp/comment.md + +# --input — raw request body from a file (or '-' for stdin). Does NOT set +# Content-Type. Without the header, JSON endpoints return HTTP 415. +glab api projects/:id/issues/:iid/notes \ + --input /tmp/body.json \ + -H "Content-Type: application/json" +``` + +### Arrays and nested objects + +`-F` / `--field` parses a value that starts with `[` or `{` as JSON, so arrays +and nested objects go inline without a file. Placeholders are expanded inside +the JSON. Invalid JSON returns an error rather than being sent as a string. + +```shell +# Array of strings +glab api -X PUT projects/:id -F 'topics=["my-topic","GitLab"]' + +# Nested object, with a placeholder expanded inside it +glab api projects/:id/merge_requests/:iid/discussions -X POST \ + -F body="looks good" \ + -F 'position={"position_type":"text","new_path":"main.go","new_line":42}' + +# Empty array clears a field +glab api -X PUT projects/:id -F 'topics=[]' +``` + +`-f` / `--raw-field` never parses JSON: a bracketed value like +`-f 'scopes=[api,read_api]'` is sent as the literal string. Use `-F` with real +JSON for arrays. On GET and DELETE requests, and whenever `--input` is used, +`-F` arrays are serialized as repeated `key[]=` query parameters. + +## Common mistakes + +- **`-m` is required on `note` commands** — without it, `glab issue note` and + `glab incident note` open `$EDITOR` (which hangs in non-interactive + environments). `glab mr note create` falls back to reading stdin on a pipe, + but still opens `$EDITOR` on a TTY. +- **Use `glab mr note create`, not `glab mr note -m`** — the `--message`, + `--unique`, `--resolve`, and `--unresolve` flags on the root `glab mr note` + command are deprecated. Use the `create`, `resolve`, and `reopen` + subcommands instead. +- **Editor-opening flags are unsafe in agent environments** — avoid + `--description "-"` on `issue create` / `mr create` / `mr update` and + avoid omitting `-m` on `note` commands. Pass an explicit value or pipe + from stdin instead. +- **`glab issue note` and `glab incident note` only post root-level + comments** — use `glab mr note create --reply` for MRs, or + `glab api .../discussions//notes` for issues/incidents (write the body + to a file and pass `-F body=@file` for anything non-trivial). +- **`--input` requires an explicit `Content-Type` header** — `glab api + --input file.json` sends raw bytes without setting Content-Type, causing + HTTP 415. Add `-H "Content-Type: application/json"` or use `-f` / `-F` + instead. +- **`glab ci retry` takes a job ID, not a pipeline ID** — to retry an + entire pipeline, use `glab api projects/:id/pipelines//retry -X POST`. +- **`glab ci trace` streams** — it blocks until the job finishes. For + agents, use `glab ci get` for pipeline state or + `glab api projects/:id/jobs//trace` to fetch a finished log. +- **`glab ci view` is interactive** — terminal UI that blocks. Use + `glab ci status` or `glab ci get` for pipeline state instead. +- **Always `--push` on `glab mr create`** — without it the remote branch + may not exist and MR creation fails. +- **No `--state` on `mr list`** — use `--all`, `--merged`, or `--closed`. +- **No `--body` flag** — `--body` is a `gh` flag. `glab` uses `--description`. +- **Labels** — `--label` to add, `--unlabel` to remove. Scoped labels like + `status::doing` auto-replace within their scope. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cbbe5be..fc26f31 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -127,8 +127,8 @@ "name": "act-gitlab-ci", "source": "./plugins/act-gitlab-ci", "displayName": "ACT GitLab CI/CD", - "description": "GitLab CI/CD and GitLab tooling for Claude Code. Six skills covering running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers; the GitLab MCP server with its full tool catalogue and version requirements; the glab CLI; CI troubleshooting; and pipeline standards translated to GitLab and marked as derived. Ships a GitLab MCP server configuration, a pipeline security review agent, and a zero-dependency pipeline checker. Relevant to anyone writing a .gitlab-ci.yml, wiring Claude into a pipeline, connecting to GitLab over MCP, or reviewing a pipeline for credential and scan compliance.", - "version": "0.2.0", + "description": "GitLab CI/CD and GitLab tooling for Claude Code. Seven skills covering automated AI review of every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) with inline discussions, sticky summaries and a tokenless fallback; running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers; the GitLab MCP server with its full tool catalogue and version requirements; the glab CLI; CI troubleshooting; and pipeline standards translated to GitLab and marked as derived. Ships a GitLab MCP server configuration, a pipeline security review agent, a zero-dependency pipeline checker, an MR review wrapper with a git-hook harness, and GitHub Copilot review instructions. Relevant to anyone writing a .gitlab-ci.yml, reviewing merge requests automatically, wiring Claude into a pipeline, connecting to GitLab over MCP, or reviewing a pipeline for credential and scan compliance.", + "version": "0.3.0", "author": { "name": "Daniel Bodnar" }, @@ -144,16 +144,19 @@ "oidc", "bedrock", "vertex", - "devops" + "code-review", + "merge-requests", + "docker-agent" ], "relevance": { - "topic": "GitLab CI/CD pipelines and GitLab tooling", + "topic": "GitLab CI/CD pipelines, merge request review, and GitLab tooling", "signals": { "filesRead": [ "**/.gitlab-ci.yml", "**/.gitlab/**", "**/gitlab-ci/**/*.yml", - "**/.gitlab-ci/*.yml" + "**/.gitlab-ci/*.yml", + "**/.github/instructions/**" ] } } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 3025c75..80fadd7 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -6,6 +6,6 @@ { "name": "act-plugin-dev", "description": "Create and review portable agent plugins.", "version": "0.2.0", "source": "./plugins/act-plugin-dev", "category": "Development" }, { "name": "act-platform-engineering", "description": "Assess and operate PostgreSQL, ZFS, Linux, Proxmox VE, and observability systems.", "version": "0.2.0", "source": "./plugins/act-platform-engineering", "category": "Operations" }, { "name": "act-work-tracking", "description": "Draft Zoho Projects work and engineering status reports.", "version": "0.2.0", "source": "./plugins/act-work-tracking", "category": "Workflow" }, - { "name": "act-gitlab-ci", "description": "Build, review, and troubleshoot GitLab CI/CD integrations.", "version": "0.2.0", "source": "./plugins/act-gitlab-ci", "category": "Engineering" } + { "name": "act-gitlab-ci", "description": "Build, review, and troubleshoot GitLab CI/CD integrations, with automated AI merge-request review.", "version": "0.3.0", "source": "./plugins/act-gitlab-ci", "category": "Engineering" } ] } diff --git a/README.md b/README.md index 45c8d26..41d396a 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ copilot plugin install act-plugin-dev@actdata-plugins | **[`act-plugin-dev`](plugins/act-plugin-dev/)**
Development | Build and review portable plugins while retaining host-specific guidance for commands, agents, hooks, and MCP. | 11 skills · 3 agents · 1 command | | **[`act-platform-engineering`](plugins/act-platform-engineering/)**
Operations | Assessment and operations for PostgreSQL, ZFS, Linux hosts and Proxmox VE. | 23 skills · 7 agents · 9 commands | | **[`act-work-tracking`](plugins/act-work-tracking/)**
Workflow | Zoho Projects work tracking and operations reporting. | 6 skills · 1 agent · 3 commands | -| **[`act-gitlab-ci`](plugins/act-gitlab-ci/)**
Engineering | GitLab CI/CD jobs, MCP, authentication, troubleshooting, and pipeline standards. | 10 skills · 1 agent · 3 commands · 1 MCP | +| **[`act-gitlab-ci`](plugins/act-gitlab-ci/)**
Engineering | GitLab CI/CD jobs, automated MR review with swappable AI engines, MCP, authentication, troubleshooting, and pipeline standards. | 13 skills · 1 agent · 5 commands · 1 MCP | ### Not yet shipped diff --git a/docs/decisions/0005-mr-review-engine-agnostic.md b/docs/decisions/0005-mr-review-engine-agnostic.md new file mode 100644 index 0000000..4a7475e --- /dev/null +++ b/docs/decisions/0005-mr-review-engine-agnostic.md @@ -0,0 +1,93 @@ +# 5. Ship automated MR review as an engine-agnostic contract inside act-gitlab-ci + +- **Status:** Accepted +- **Date:** 2026-08-15 + +## Context + +The repository was asked for automated code review of GitLab merge requests: an agent runs in a +CI pipeline on every submitted MR and posts feedback comparable to GitHub Copilot's or +claude-code-action's pull-request reviews, with Docker's `docker-agent` as the runner. The +requirement then widened along two axes: the review must also run **without docker-agent** (a +plain pipeline, a git hook, a normal editor session) and **beyond GitLab** (GitHub Copilot's +native code review, and Codex/Copilot hosts, which this marketplace already publishes to via +tri-runtime manifests). + +Two structural questions followed: + +1. **Where does it live?** ADR 0003 split the operational bundle into three plugins by topic. + MR review is a new capability that touches GitLab CI (its primary surface) but also ships a + GitHub artifact. +2. **What is the unit of reuse?** A docker-agent job, a Claude Code job, a pre-push hook, and a + Copilot instructions file cannot share code. They can share judgment. + +Two facts constrain any GitLab design: + +- `CI_JOB_TOKEN` cannot create notes or discussions on a merge request, so posting review + comments requires a user-provisioned project access token. +- The diff under review is untrusted input to an unattended model; whatever runs the review is a + prompt-injection target and must be treated as such. + +## Options considered + +| Option | Assessment | +|---|---| +| **A fifth plugin (`act-code-review`)** | Honest about the GitHub artifact. Rejected: it would duplicate act-gitlab-ci's CI knowledge (tokens, triggers, cost bounds), and its GitLab surface — the primary one — would depend on a sibling plugin, which `CONTRIBUTING.md` forbids referencing across plugin directories. | +| **Extend act-gitlab-ci** | Chosen. The primary surface is GitLab CI, the plugin already owns Claude-in-CI and pipeline review, and the marketplace's tri-runtime manifests already publish it to Copilot and Codex hosts. The GitHub Copilot instructions template is a recorded tension: a `.github/` artifact in a GitLab-named plugin. If the capability outgrows this home, splitting it out is a version bump, and this ADR is where that revisit starts. | +| **The agent posts its own comments (fetch toolset, GitLab MCP, or glab)** | Rejected. It hands the GitLab token to a process parsing untrusted input; the discussions API's position contract is exacting and a model gets it wrong at a steady rate; the HTTP MCP server authenticates over OAuth and is unusable in CI; and none of it is testable without live GitLab. | +| **A two-stage contract: engines produce findings JSON, a deterministic script posts** | Chosen. The rubric plus findings schema (`review-rubric.md`) is the stable center; engines (docker-agent, claude, codex, copilot, or any command) are adapters; one wrapper owns every side effect and is pinned by a fixture test suite. | +| **Claude Code as the only engine** | Rejected. It collapses the provider-agnostic requirement to one vendor, and the plugin already documents Claude-in-CI as an *actor* (`claude-code-ci-jobs`); conflating actor and reviewer in one job blurs the security boundary between a read-only process and one that commits. | + +## Decision + +Extend `act-gitlab-ci` (0.2.0 to 0.3.0) with an engine-agnostic review capability: + +**1. One contract.** `skills/mr-review-agent/references/review-rubric.md` defines what a reviewer +reports, the severity scale, and the findings JSON schema. Every surface derives from it, and the +Copilot instructions file restates it; a rubric change is a change to all of them. + +**2. Deterministic delivery.** `scripts/post-mr-review.ts` (CI) and `scripts/ai-review.sh` +(hooks, ad-hoc) run the engine, validate its output against the contract, and deliver findings. +Delivery modes: `inline` (positioned discussions plus a sticky, marker-identified summary note; +the default), `summary`, and `log` — the automatic fallback when no `GITLAB_TOKEN` exists, +because `CI_JOB_TOKEN` cannot post. Positions GitLab rejects (HTTP 400) degrade per finding to +plain notes rather than being dropped. + +**3. The engine is confined.** Read-only toolsets in the shipped docker-agent config; the wrapper +strips `GITLAB_TOKEN` from the engine's environment; timeouts, `allow_failure: true`, +`interruptible: true`, diff budgets, and turn caps bound cost. The reviewer never blocks a merge. + +**4. Scripts are copied into target repositories** (`.gitlab/ai-review/`) by the +`setup-mr-review` command, because CI jobs cannot resolve `${CLAUDE_PLUGIN_ROOT}`. The canonical, +tested copies stay in the plugin; re-running the command refreshes them. + +**5. The docker-agent binary is version-pinned** (`DOCKER_AGENT_VERSION`) and downloaded at job +runtime — the repository tracks no binaries. Despite the name, no Docker daemon is involved; the +binary is standalone, which is what makes the "runs in a normal pipeline" requirement hold even +for the default engine. + +## Consequences + +**A GitHub artifact ships in a GitLab-named plugin.** `copilot-code-review.instructions.md` and +the `copilot` setup surface sit in `act-gitlab-ci` because the review contract, not the host, is +the unit of cohesion. The tension is real and recorded here; a future `act-code-review` split +would move the rubric and its restatements together. + +**The token guidance now has a documented exception.** `claude-code-ci-jobs` says to prefer +`CI_JOB_TOKEN`; posting review comments is a case its permissions cannot cover, and the +`mr-review-agent` skill states when each applies. Without a project access token the capability +degrades to `log` mode rather than failing. + +**Two engines are tested, two are best-effort.** docker-agent and claude invocations are exercised +by the fixture suite; codex and copilot CLI flags are young and verified only at setup time, with +`AI_REVIEW_ENGINE_CMD` as the escape hatch. The suite (`scripts/tests/mr-review/`) runs with no +network and no engines, so the repository gate pins parsing, positioning, the 400 fallback, mode +downgrades, marker stickiness, and re-push resolution — not model quality. + +**The rubric's restatements can drift.** The Copilot instructions file is a manual restatement of +the rubric, and nothing mechanical keeps them aligned; the skill instructs that they change +together. Drift here degrades one surface's judgment, not correctness of delivery. + +**docker-agent version bumps are deliberate work.** Headless flags, event shapes, and safety +semantics move between releases; the pin, the transcript artifact, and the tolerant parser limit +the blast radius, and the reference documents what to re-check on a bump. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d821949..8361940 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -15,6 +15,7 @@ what it cost to arrive at, needs one. | [0002](0002-config-driven-plugins.md) | Ship no environment identifiers; read them from a site-local settings file | Accepted | | [0003](0003-three-plugin-split.md) | Split the operational bundle into three plugins | Accepted | | [0004](0004-derived-pipeline-standards.md) | Ship pipeline standards as explicitly derived, and record the platform conflict | Accepted | +| [0005](0005-mr-review-engine-agnostic.md) | Ship automated MR review as an engine-agnostic contract inside act-gitlab-ci | Accepted | ## Format diff --git a/plugins/act-gitlab-ci/.claude-plugin/plugin.json b/plugins/act-gitlab-ci/.claude-plugin/plugin.json index a75f73f..3ef9ff1 100644 --- a/plugins/act-gitlab-ci/.claude-plugin/plugin.json +++ b/plugins/act-gitlab-ci/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "act-gitlab-ci", - "version": "0.2.0", - "description": "GitLab CI/CD and GitLab tooling for Claude Code. Covers running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers, the GitLab MCP server, the glab CLI, and pipeline standards adapted for GitLab. Includes a zero-dependency pipeline validator.", + "version": "0.3.0", + "description": "GitLab CI/CD and GitLab tooling for Claude Code. Covers automated AI review of merge requests through swappable engines (docker-agent, Claude Code, Codex, Copilot), running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers, the GitLab MCP server, the glab CLI, and pipeline standards adapted for GitLab. Includes a zero-dependency pipeline validator, an MR review wrapper with a git-hook harness, and GitHub Copilot review instructions.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com" @@ -20,6 +20,8 @@ "oidc", "bedrock", "vertex", - "devops" + "code-review", + "merge-requests", + "docker-agent" ] } diff --git a/plugins/act-gitlab-ci/.codex-plugin/plugin.json b/plugins/act-gitlab-ci/.codex-plugin/plugin.json index 90befb3..52d04b2 100644 --- a/plugins/act-gitlab-ci/.codex-plugin/plugin.json +++ b/plugins/act-gitlab-ci/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "act-gitlab-ci", "version": "0.2.0", "description": "GitLab CI/CD, MCP, authentication, troubleshooting, and pipeline review workflows for ACT Data.", + "name": "act-gitlab-ci", "version": "0.3.0", "description": "GitLab CI/CD, automated merge-request review, MCP, authentication, troubleshooting, and pipeline review workflows for ACT Data.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", - "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "devops"], "skills": "./skills/", "mcpServers": "./.mcp.json", - "interface": { "displayName": "ACT GitLab CI/CD", "shortDescription": "Build, review, and troubleshoot GitLab pipelines", "longDescription": "Configure GitLab CI agent jobs, connect GitLab MCP, review pipeline controls, and troubleshoot authentication and execution failures.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Review this GitLab pipeline against ACT standards.", "Help me connect the GitLab MCP server."] } + "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "code-review", "devops"], "skills": "./skills/", "mcpServers": "./.mcp.json", + "interface": { "displayName": "ACT GitLab CI/CD", "shortDescription": "Build, review, and troubleshoot GitLab pipelines", "longDescription": "Configure GitLab CI agent jobs, set up automated AI review of merge requests, connect GitLab MCP, review pipeline controls, and troubleshoot authentication and execution failures.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Review this GitLab pipeline against ACT standards.", "Set up automated AI review of merge requests.", "Help me connect the GitLab MCP server."] } } diff --git a/plugins/act-gitlab-ci/README.md b/plugins/act-gitlab-ci/README.md index 7ff4d35..0e8d9b8 100644 --- a/plugins/act-gitlab-ci/README.md +++ b/plugins/act-gitlab-ci/README.md @@ -7,11 +7,11 @@ # act-gitlab-ci -GitLab CI/CD, the GitLab MCP server, the `glab` CLI, and pipeline standards. +GitLab CI/CD, automated merge-request review, the GitLab MCP server, the `glab` CLI, and pipeline standards. -![skills](https://img.shields.io/badge/skills-10-00A8E1?labelColor=003767) +![skills](https://img.shields.io/badge/skills-13-00A8E1?labelColor=003767) ![agents](https://img.shields.io/badge/agents-1-003767) -![commands](https://img.shields.io/badge/commands-3-147EC2) +![commands](https://img.shields.io/badge/commands-5-147EC2) ![mcp](https://img.shields.io/badge/mcp-gitlab-00817D) ![deps](https://img.shields.io/badge/dependencies-none-58585B) @@ -27,6 +27,7 @@ GitLab CI/CD, the GitLab MCP server, the `glab` CLI, and pipeline standards. - [Commands](#commands) - [Install](#install) - [Configuration](#configuration) +- [Automated MR review](#automated-mr-review) - [The pipeline checker](#the-pipeline-checker) - [Standards provenance](#standards-provenance) - [What this plugin does NOT do](#what-this-plugin-does-not-do) @@ -34,12 +35,15 @@ GitLab CI/CD, the GitLab MCP server, the `glab` CLI, and pipeline standards. ## What this is -Four related things: +Five related things: -1. **Running Claude Code as a GitLab CI job**, across the Claude API, Amazon Bedrock and Google Cloud. -2. **The GitLab MCP server**, for interactive sessions. -3. **The `glab` CLI**, which is often the better tool for scripted GitLab work. -4. **Pipeline standards**, translated to GitLab and marked as derived throughout. +1. **Automated AI review of every merge request**, engine-agnostic (docker-agent, Claude Code, + Codex, Copilot), with the same rubric available as a git hook, an in-session command, and + GitHub Copilot review instructions. +2. **Running Claude Code as a GitLab CI job**, across the Claude API, Amazon Bedrock and Google Cloud. +3. **The GitLab MCP server**, for interactive sessions. +4. **The `glab` CLI**, which is often the better tool for scripted GitLab work. +5. **Pipeline standards**, translated to GitLab and marked as derived throughout. > [!IMPORTANT] > **Two different things are called the GitLab MCP server.** The HTTP server at @@ -51,16 +55,17 @@ Four related things: | Component | Count | What it is | |---|---|---| -| Skills | 10 | CI jobs, auth providers, MCP server, `glab`, troubleshooting, standards, and portable command/agent adapters | +| Skills | 13 | MR review, CI jobs, auth providers, MCP server, `glab`, troubleshooting, standards, and portable command/agent adapters | | Agents | 1 | Pipeline security review | -| Commands | 3 | Set up the job, review a pipeline, connect MCP | +| Commands | 5 | Set up MR review, review an MR, set up the Claude job, review a pipeline, connect MCP | | MCP servers | 1 | GitLab, over HTTP | -| Scripts | 1 | Zero-dependency pipeline checker with 23 tests | +| Scripts | 3 | Zero-dependency pipeline checker, MR review wrapper, and engine-dispatch harness, with two fixture-driven test suites | ## Skills | Skill | What it covers | |---|---| +| [`mr-review-agent`](skills/mr-review-agent/) | Automated MR review: the rubric and findings contract, engines, delivery modes, tokens, re-push semantics. Templates for the CI job, the docker-agent config, a pre-push hook and Copilot instructions. | | [`claude-code-ci-jobs`](skills/claude-code-ci-jobs/) | Job definition, trigger rules, `AI_FLOW_*`, CLI flags, cost bounds. Three complete job examples. | | [`ci-auth-providers`](skills/ci-auth-providers/) | Claude API, Bedrock over OIDC, Google Cloud over WIF | | [`gitlab-mcp-server`](skills/gitlab-mcp-server/) | Enabling, connecting, ~26 tools with version requirements, security | @@ -72,6 +77,8 @@ Four related things: | Command | Does | |---|---| +| `/act-gitlab-ci:setup-mr-review` | Install automated review as a CI MR job, a git hook, or Copilot instructions | +| `/act-gitlab-ci:review-mr` | Review one merge request in-session against the shared rubric | | `/act-gitlab-ci:setup-claude-job` | Add a Claude Code job with a provider, trigger rules and cost bounds | | `/act-gitlab-ci:review-pipeline` | Review a pipeline, separating verified findings from what cannot be checked | | `/act-gitlab-ci:connect-gitlab-mcp` | Connect the MCP server, checking prerequisites first | @@ -117,6 +124,33 @@ Minimum GitLab 18.6 for beta. Free, Premium and Ultimate all qualify. > than the catalogue lists. Check `skills/gitlab-mcp-server/references/tool-catalogue.md` before > concluding a tool is broken. +## Automated MR review + +One review rubric and findings contract, four ways to run it: + +| Surface | How | Setup | +|---|---|---| +| GitLab CI, every MR | A job runs an AI engine over the diff and posts the review | `/act-gitlab-ci:setup-mr-review gitlab-ci` | +| Git hook / scripts | `ai-review.sh` reviews a local diff with whatever engine is on PATH | `/act-gitlab-ci:setup-mr-review git-hook` | +| In-session | `/act-gitlab-ci:review-mr` on any host the plugin is installed in | none | +| GitHub Copilot native review | A `.github/instructions/` file carries the rubric | `/act-gitlab-ci:setup-mr-review copilot` | + +Engines are swappable (`AI_REVIEW_ENGINE`): `docker-agent` (default, provider-agnostic, a pinned +standalone binary -- no Docker daemon), `claude`, `codex`, `copilot`, or any command via +`AI_REVIEW_ENGINE_CMD`. Delivery is mode-selected (`AI_REVIEW_MODE`): `inline` posts one +positioned discussion per finding plus a sticky summary, `summary` posts the note alone, `log` +writes to the job log and artifacts. + +> [!IMPORTANT] +> Posting MR comments requires a project access token (`GITLAB_TOKEN`, `api` scope, masked): +> `CI_JOB_TOKEN` cannot create notes. Without one, the job automatically falls back to `log` +> mode. The reviewer never blocks a merge (`allow_failure: true`), and the engine never sees the +> GitLab token -- the diff it reviews is untrusted input. Do not expose the token or the model +> API key to pipelines from forks. + +See [`skills/mr-review-agent/`](skills/mr-review-agent/) for the rubric, mode and engine +matrices, and the security model. + ## The pipeline checker ```sh @@ -154,12 +188,20 @@ period, no coverage threshold and no scan severity gate. ## What this plugin does NOT do > [!CAUTION] -> `setup-claude-job` writes to your `.gitlab-ci.yml`. Everything else is read-only. +> `setup-claude-job` and `setup-mr-review` write to your repository (`.gitlab-ci.yml`, +> `.gitlab/ai-review/`, `.github/instructions/`, hooks). Everything else is read-only, and +> `review-mr` posts to an MR only on explicit confirmation. -- **No credential handling.** It never reads, writes or prompts for a token. Variables are set in the - GitLab UI, which the plugin tells you to do and cannot do for you. +- **No credential handling.** It never reads, writes or prompts for a token or model API key. + Variables are set in the GitLab UI, which the plugin tells you to do and cannot do for you. - **No webhook setup.** `@claude` mentions need a listener calling the pipeline trigger API. GitLab - does not do this natively and this plugin does not build it. + does not do this natively and this plugin does not build it. MR-event triggering is native and + is what the review job uses. +- **The reviewer never gates a merge.** The review job runs with `allow_failure: true`, and the + pre-push hook is advisory unless `AI_REVIEW_BLOCKING=1`. Generated findings are advice to + verify, not policy. +- **Copilot native reviews are configured, not executed.** The instructions file only takes + effect where Copilot code review is enabled on the GitHub side. - **No authority on standards.** See above. It reports derived rules as derived. - **No GitLab administration.** Enabling Duo, beta features and MCP access are admin or group-owner actions. @@ -174,11 +216,14 @@ act-gitlab-ci/ README.md agents/pipeline-security-reviewer.md commands/ + setup-mr-review.md review-mr.md setup-claude-job.md review-pipeline.md connect-gitlab-mcp.md scripts/ - check-pipeline.ts + check-pipeline.ts post-mr-review.ts ai-review.sh tests/ run-tests.sh compliant/ violating/ + tests/mr-review/ run-tests.sh unit.test.ts fixtures/ skills/ + mr-review-agent/ SKILL.md + references/(5) + examples/(4) claude-code-ci-jobs/ SKILL.md + examples/(3 yml) ci-auth-providers/ SKILL.md gitlab-mcp-server/ SKILL.md + references/(2) diff --git a/plugins/act-gitlab-ci/commands/review-mr.md b/plugins/act-gitlab-ci/commands/review-mr.md new file mode 100644 index 0000000..d918667 --- /dev/null +++ b/plugins/act-gitlab-ci/commands/review-mr.md @@ -0,0 +1,57 @@ +--- +description: Review a GitLab merge request against the shared rubric, in-session, and optionally post the findings +argument-hint: "[MR IID, MR URL, or a git range]" +allowed-tools: Read, Bash, Grep, Glob +--- + +# Review a merge request in-session + +Apply the review rubric to a merge request (or a local range) and report findings in the +conversation. The same rubric drives the CI job and the git hook; this is the surface for +reviewing one MR on demand, with no pipeline involved. + +## Resolve the target + +From `$ARGUMENTS`: + +- An MR IID or URL: use it directly. +- A git range (contains `..`): review the local diff of that range and skip the GitLab steps. +- Nothing: find the MR for the current branch with `glab mr view --output json`, and if there is + none, fall back to the local diff against the default branch. + +## Obtain the diff + +Prefer, in order: + +1. `glab mr diff ` when the glab CLI is authenticated. +2. The API: `GET /projects/:id/merge_requests/:iid/changes` via `glab api`, which also returns + `diff_refs` (needed later for posting inline). +3. A local `git diff ...` when GitLab is unreachable. + +Also fetch the MR title and description; intent matters when judging a diff. + +## Apply the rubric + +Read `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/references/review-rubric.md` and follow it +exactly: changed lines only, the listed defect classes in priority order, the exclusions, and the +blocker/warning/nit severity discipline. Read surrounding files from the checkout when a changed +line cannot be judged alone. + +## Report + +Present the findings in the conversation: a one-paragraph summary, then each finding as +`[SEVERITY] path:line -- title` with the failure scenario and a concrete suggestion. A clean +review says so explicitly. Never pad: an empty findings list is a valid, common outcome. + +## Posting is opt-in + +After reporting, offer to post the review to the MR -- and only proceed on an explicit yes: + +- With the glab CLI or a `GITLAB_TOKEN` available, post one summary note. Append the marker line + `` so the CI job's sticky-note + logic recognizes and updates it instead of duplicating it. Inline positioned discussions from an interactive + session are rarely worth the fragility; the CI job owns that surface. +- With no credentials, say so and leave the review in the conversation. + +Never post without asking, and never include anything in the note that did not appear in the +reported findings. diff --git a/plugins/act-gitlab-ci/commands/setup-mr-review.md b/plugins/act-gitlab-ci/commands/setup-mr-review.md new file mode 100644 index 0000000..f657f8b --- /dev/null +++ b/plugins/act-gitlab-ci/commands/setup-mr-review.md @@ -0,0 +1,82 @@ +--- +description: Install automated AI code review, as a GitLab CI MR job, a git hook, or GitHub Copilot review instructions +argument-hint: "[gitlab-ci|copilot|git-hook] [inline|summary|log] [docker-agent|claude|codex|copilot]" +allowed-tools: Read, Write, Edit, Bash, Grep, Glob +--- + +# Set up automated code review + +Install the review bundle from the `mr-review-agent` skill into the current repository, on one of +three surfaces. Load that skill first for the architecture, mode matrix, and security constraints. + +## Choose the surface + +From the first word of `$ARGUMENTS`, or ask: + +| Surface | When | +|---|---| +| `gitlab-ci` | Review every merge request in the pipeline. The primary surface. | +| `git-hook` | Review locally before pushing; no CI, no tokens. | +| `copilot` | The repository is reviewed on GitHub by Copilot's native reviewer. | + +## gitlab-ci + +1. Copy from the plugin into the repository at `.gitlab/ai-review/`: + - `${CLAUDE_PLUGIN_ROOT}/scripts/post-mr-review.ts` + - `${CLAUDE_PLUGIN_ROOT}/scripts/ai-review.sh` + - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/references/review-rubric.md` + - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/review-agent.yaml` + + The copy is deliberate: a CI job cannot resolve plugin paths. Re-running this command later + refreshes the copies. + +2. Read the repository's `.gitlab-ci.yml` if there is one and match its stage names and + conventions. Then add the job from + `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/mr-review-job.yml`, adapting: + - `AI_REVIEW_MODE` from the second argument (default `inline`). + - `AI_REVIEW_ENGINE` from the third argument (default `docker-agent`). + - Keep `timeout`, `allow_failure: true`, `interruptible: true`, and the draft-skip rules; they + are cost and safety bounds, not decoration. + +3. If the engine is `docker-agent`, set `model:` in `.gitlab/ai-review/review-agent.yaml` to the + user's provider and model. Ask rather than guess the provider. + +4. Tell the user which CI/CD variables to create under Settings, CI/CD, Variables. Never handle + the values: + - The model provider key (for example `ANTHROPIC_API_KEY`), masked. + - `GITLAB_TOKEN` for `inline` or `summary` mode: a project access token, `api` scope, + Developer role, masked. State plainly that without it the review lands in the job log and + artifacts only, and that neither variable may be exposed to fork pipelines. + +5. Recommend the first run: open a test MR and check the job log, the artifacts, and the posted + review before trusting it on real work. + +## git-hook + +1. Copy `post-mr-review.ts`, `ai-review.sh`, `review-rubric.md`, and `review-agent.yaml` to + `.gitlab/ai-review/` as above (the harness and hook resolve them there). +2. Install `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/git-hook-pre-push.sh`: + - If the repository uses a hook manager (lefthook, husky, `core.hooksPath`), add it there and + say where. + - Otherwise copy it to `.git/hooks/pre-push` and mark it executable. Note that `.git/hooks` is + per-clone and not versioned, so each contributor installs it themselves. +3. State the defaults: advisory (findings print, the push proceeds), `AI_REVIEW_BLOCKING=1` gates, + `git push --no-verify` bypasses, and the engine is auto-detected from PATH unless + `AI_REVIEW_ENGINE` is set. + +## copilot + +1. Copy `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/copilot-code-review.instructions.md` + to `.github/instructions/code-review.instructions.md`. +2. Tell the user what the file cannot do by itself: Copilot code review must be enabled for the + repository, the custom-instructions toggle under Settings, Copilot, Code review must be on, + and only pull requests whose head branch contains the file are affected. + +## Review before finishing + +For the `gitlab-ci` surface, run the pipeline checker over the result and report which findings +came from this change: + +```bash +bun "${CLAUDE_PLUGIN_ROOT}/scripts/check-pipeline.ts" .gitlab-ci.yml +``` diff --git a/plugins/act-gitlab-ci/plugin.json b/plugins/act-gitlab-ci/plugin.json index 04da434..f5c11ce 100644 --- a/plugins/act-gitlab-ci/plugin.json +++ b/plugins/act-gitlab-ci/plugin.json @@ -1,6 +1,6 @@ { - "name": "act-gitlab-ci", "version": "0.2.0", "description": "Build, review, and troubleshoot GitLab CI/CD integrations.", + "name": "act-gitlab-ci", "version": "0.3.0", "description": "Build, review, and troubleshoot GitLab CI/CD integrations, with automated AI merge-request review.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", - "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "devops"], "skills": "skills/", "commands": "commands/", "mcpServers": ".mcp.json" + "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "code-review", "devops"], "skills": "skills/", "commands": "commands/", "mcpServers": ".mcp.json" } diff --git a/plugins/act-gitlab-ci/scripts/ai-review.sh b/plugins/act-gitlab-ci/scripts/ai-review.sh new file mode 100644 index 0000000..420af85 --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/ai-review.sh @@ -0,0 +1,174 @@ +#!/bin/sh +# ============================================================================= +# ai-review -- run the review rubric over a local diff with whatever AI engine +# is installed, and print the findings. +# +# The engine-agnostic harness for scripted surfaces: git hooks, ad-hoc runs, +# and anywhere the GitLab CI wrapper (post-mr-review.ts) does not apply. It +# never talks to GitLab; it reviews a local diff and reports to stdout. +# +# Usage: +# ai-review.sh [git diff arguments] # default: git diff HEAD +# ai-review.sh origin/main...HEAD # a pre-push style range +# +# Environment: +# AI_REVIEW_ENGINE docker-agent | claude | codex | copilot +# (default: first of those found on PATH) +# AI_REVIEW_ENGINE_CMD full command run via sh -c, prompt on stdin; +# overrides AI_REVIEW_ENGINE entirely +# AI_REVIEW_ENGINE_FLAGS safety flags for docker-agent (default: +# --safety restricted) +# AI_REVIEW_AGENT_CONFIG docker-agent config path (default: +# review-agent.yaml next to this script) +# AI_REVIEW_RUBRIC rubric path (default: review-rubric.md next to +# this script, then the plugin skill copy) +# AI_REVIEW_MAX_TURNS turn cap for CLI engines (default 25) +# AI_REVIEW_DIFF_FILE read the diff from a file instead of running git +# AI_REVIEW_BLOCKING "1" exits 1 when a blocker is found (for hooks +# that gate; default advisory) +# +# Exit: 0 review printed (or empty diff), 1 engine/contract failure or a +# blocker under AI_REVIEW_BLOCKING=1, 2 configuration error. +# ============================================================================= + +set -u + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +err() { printf 'ai-review: %s\n' "$1" >&2; } + +# --- locate the rubric ------------------------------------------------------- + +RUBRIC="${AI_REVIEW_RUBRIC:-}" +if [ -z "$RUBRIC" ]; then + for candidate in \ + "$SCRIPT_DIR/review-rubric.md" \ + "$SCRIPT_DIR/../skills/mr-review-agent/references/review-rubric.md"; do + if [ -f "$candidate" ]; then RUBRIC="$candidate"; break; fi + done +fi +if [ -z "$RUBRIC" ] || [ ! -f "$RUBRIC" ]; then + err "rubric not found; set AI_REVIEW_RUBRIC" + exit 2 +fi + +# --- collect the diff -------------------------------------------------------- + +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/ai-review.XXXXXX") || exit 2 +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +DIFF_FILE="$WORKDIR/diff.patch" +if [ -n "${AI_REVIEW_DIFF_FILE:-}" ]; then + cp "$AI_REVIEW_DIFF_FILE" "$DIFF_FILE" || exit 2 +elif [ $# -gt 0 ]; then + git diff --no-color "$@" >"$DIFF_FILE" || { err "git diff failed"; exit 2; } +else + git diff --no-color HEAD >"$DIFF_FILE" || { err "git diff failed"; exit 2; } +fi + +if [ ! -s "$DIFF_FILE" ]; then + echo "ai-review: empty diff, nothing to review." + exit 0 +fi + +# --- build the prompt -------------------------------------------------------- + +PROMPT_FILE="$WORKDIR/prompt.md" +{ + cat "$RUBRIC" + printf '\n\nRespond with ONLY the findings-contract JSON object. No prose before or after it.\n' + printf '\nDiff under review:\n```diff\n' + cat "$DIFF_FILE" + printf '```\n' +} >"$PROMPT_FILE" + +# --- pick and run the engine ------------------------------------------------- +# The engine reviews untrusted diff content; run it with no GitLab credentials +# in its environment. + +OUT_FILE="$WORKDIR/engine-output.txt" +MAX_TURNS="${AI_REVIEW_MAX_TURNS:-25}" + +run_engine() { + if [ -n "${AI_REVIEW_ENGINE_CMD:-}" ]; then + GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= sh -c "$AI_REVIEW_ENGINE_CMD" <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + return $? + fi + + engine="${AI_REVIEW_ENGINE:-}" + if [ -z "$engine" ]; then + for candidate in docker-agent claude codex copilot; do + if command -v "$candidate" >/dev/null 2>&1; then engine="$candidate"; break; fi + done + fi + if [ -z "$engine" ]; then + err "no engine found (docker-agent, claude, codex or copilot); set AI_REVIEW_ENGINE_CMD" + return 2 + fi + + case "$engine" in + docker-agent) + config="${AI_REVIEW_AGENT_CONFIG:-$SCRIPT_DIR/review-agent.yaml}" + [ -f "$config" ] || config="$SCRIPT_DIR/../skills/mr-review-agent/examples/review-agent.yaml" + # shellcheck disable=SC2086 -- flags are deliberately word-split + GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= TELEMETRY_ENABLED=false \ + docker-agent run --exec "$config" --json ${AI_REVIEW_ENGINE_FLAGS:---safety restricted} - \ + <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + ;; + claude) + GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ + claude -p --output-format json --max-turns "$MAX_TURNS" --allowedTools "Read Grep Glob" \ + <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + ;; + codex) + # Best effort: verify flags against `codex exec --help` for the + # installed version; override with AI_REVIEW_ENGINE_CMD on drift. + GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ + codex exec --json <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + ;; + copilot) + # Best effort: verify flags against `copilot --help` for the installed + # version; override with AI_REVIEW_ENGINE_CMD on drift. + GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ + copilot -p "$(cat "$PROMPT_FILE")" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + ;; + *) + err "unknown AI_REVIEW_ENGINE \"$engine\"" + return 2 + ;; + esac +} + +run_engine +engine_status=$? +if [ "$engine_status" -eq 2 ]; then + exit 2 +fi +if [ ! -s "$OUT_FILE" ]; then + err "engine produced no output (exit $engine_status)" + [ -s "$WORKDIR/engine-stderr.txt" ] && cat "$WORKDIR/engine-stderr.txt" >&2 + exit 1 +fi + +# --- report ------------------------------------------------------------------ +# Prefer the wrapper's parser (same contract as CI); fall back to a raw dump +# plus a grep when bun is not installed. + +EXTRACTOR="$SCRIPT_DIR/post-mr-review.ts" +if command -v bun >/dev/null 2>&1 && [ -f "$EXTRACTOR" ]; then + if [ "${AI_REVIEW_BLOCKING:-0}" = "1" ]; then + bun "$EXTRACTOR" --extract "$OUT_FILE" --report --blocking + else + bun "$EXTRACTOR" --extract "$OUT_FILE" --report + fi + exit $? +fi + +cat "$OUT_FILE" +if [ "${AI_REVIEW_BLOCKING:-0}" = "1" ] && + grep -Eq '"severity"[[:space:]]*:[[:space:]]*"blocker"' "$OUT_FILE"; then + err "blocker finding present" + exit 1 +fi +exit 0 diff --git a/plugins/act-gitlab-ci/scripts/post-mr-review.ts b/plugins/act-gitlab-ci/scripts/post-mr-review.ts new file mode 100644 index 0000000..8519e32 --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/post-mr-review.ts @@ -0,0 +1,757 @@ +#!/usr/bin/env bun +/** + * post-mr-review -- run an AI review engine over a merge request and deliver + * the findings to GitLab. + * + * Zero dependencies, node: builtins only, run under bun. + * + * Designed to run inside a GitLab CI merge-request pipeline, copied into the + * target repository (CI cannot resolve plugin paths). The engine produces + * findings; this script does everything with side effects: it builds the + * prompt, spawns the engine, validates the output against the findings + * contract (see review-rubric.md), and posts the review. + * + * Modes (AI_REVIEW_MODE, default "inline"): + * inline one positioned discussion per finding + a sticky summary note; + * findings whose position GitLab rejects degrade to plain notes + * summary a single sticky summary note + * log job log + artifacts only; the automatic fallback when no + * GITLAB_TOKEN is set, because CI_JOB_TOKEN cannot create MR notes + * + * Engines (AI_REVIEW_ENGINE, default "docker-agent"): + * docker-agent | claude | codex | copilot | custom via AI_REVIEW_ENGINE_CMD + * + * The engine subprocess never receives GITLAB_TOKEN: merge-request code is + * untrusted input to the model, and a prompt-injected engine must have nothing + * to exfiltrate and no way to post. + * + * Sticky semantics: every body this script posts carries an HTML marker with + * the reviewed head SHA. On re-push it resolves its own stale discussions, + * posts fresh ones, and updates the summary note in place. A marker matching + * the current head SHA makes the run a no-op, so pipeline retries are free. + * + * Usage: + * bun post-mr-review.ts # normal CI entry point + * bun post-mr-review.ts --extract FILE # parse engine output, print JSON + * [--report] # human-readable report instead + * [--blocking] # exit 1 when blockers found + * + * Exit: 0 review delivered (or nothing to do), 1 engine/contract failure, + * 2 configuration error. Never nonzero for findings alone unless + * --blocking asked for it. + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +// --------------------------------------------------------------------------- +// Findings contract +// --------------------------------------------------------------------------- + +export type Severity = "blocker" | "warning" | "nit"; + +export interface Finding { + path: string; + new_line: number | null; + old_line: number | null; + severity: Severity; + title: string; + body: string; +} + +export interface Review { + summary: string; + findings: Finding[]; +} + +const SEVERITIES: readonly string[] = ["blocker", "warning", "nit"]; + +export function validateReview(value: unknown): { review: Review | null; errors: string[] } { + const errors: string[] = []; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { review: null, errors: ["not an object"] }; + } + const obj = value as Record; + if (typeof obj.summary !== "string" || obj.summary.length === 0) { + errors.push("summary: required string"); + } + if (!Array.isArray(obj.findings)) { + errors.push("findings: required array"); + return { review: null, errors }; + } + const findings: Finding[] = []; + obj.findings.forEach((raw, i) => { + if (typeof raw !== "object" || raw === null) { + errors.push(`findings[${i}]: not an object`); + return; + } + const f = raw as Record; + const where = `findings[${i}]`; + if (typeof f.path !== "string" || f.path.length === 0) errors.push(`${where}.path: required string`); + if (typeof f.title !== "string" || f.title.length === 0) errors.push(`${where}.title: required string`); + if (typeof f.body !== "string" || f.body.length === 0) errors.push(`${where}.body: required string`); + if (!SEVERITIES.includes(f.severity as string)) errors.push(`${where}.severity: must be blocker|warning|nit`); + const newLine = f.new_line === undefined || f.new_line === null ? null : f.new_line; + const oldLine = f.old_line === undefined || f.old_line === null ? null : f.old_line; + if (newLine !== null && (typeof newLine !== "number" || !Number.isInteger(newLine) || newLine < 1)) { + errors.push(`${where}.new_line: must be a positive integer or null`); + } + if (oldLine !== null && (typeof oldLine !== "number" || !Number.isInteger(oldLine) || oldLine < 1)) { + errors.push(`${where}.old_line: must be a positive integer or null`); + } + if (newLine === null && oldLine === null) { + errors.push(`${where}: needs new_line (or old_line for a deletion)`); + } + findings.push({ + path: String(f.path), + new_line: newLine as number | null, + old_line: oldLine as number | null, + severity: f.severity as Severity, + title: String(f.title), + body: String(f.body), + }); + }); + if (errors.length > 0) return { review: null, errors }; + return { review: { summary: String(obj.summary), findings }, errors: [] }; +} + +/** + * Pull a Review out of whatever an engine printed. + * + * Engines differ: docker-agent --json emits newline-delimited events, claude + * --output-format json wraps its answer in a result object, others print + * prose around a fenced JSON block. Try, in order: the whole text as JSON, + * each ndjson line, fenced ```json blocks, and the outermost brace slice. + * String fields of intermediate objects are searched recursively, and the + * LAST valid candidate wins (later events supersede earlier ones). + */ +export function extractReview(raw: string, depth = 0): Review | null { + if (depth > 4 || raw.length === 0) return null; + let last: Review | null = null; + + const consider = (candidate: unknown): void => { + const direct = validateReview(candidate).review; + if (direct) { + last = direct; + return; + } + if (typeof candidate === "object" && candidate !== null) { + for (const v of Object.values(candidate as Record)) { + if (typeof v === "string" && v.includes("{")) { + const nested = extractReview(v, depth + 1); + if (nested) last = nested; + } else if (typeof v === "object" && v !== null) { + consider(v); + } + } + } + }; + + const tryParse = (text: string): void => { + try { + consider(JSON.parse(text)); + } catch { + /* not JSON; other strategies below */ + } + }; + + tryParse(raw.trim()); + if (last) return last; + + for (const line of raw.split("\n")) { + const t = line.trim(); + if (t.startsWith("{") && t.endsWith("}")) tryParse(t); + } + if (last) return last; + + for (const m of raw.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)) { + tryParse(m[1].trim()); + } + if (last) return last; + + const first = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (first !== -1 && end > first) tryParse(raw.slice(first, end + 1)); + + return last; +} + +// --------------------------------------------------------------------------- +// Mode and engine resolution +// --------------------------------------------------------------------------- + +export type Mode = "inline" | "summary" | "log"; + +export function resolveMode(requested: string | undefined, hasToken: boolean): { mode: Mode; downgraded: boolean } { + const wanted = (requested || "inline").toLowerCase(); + if (wanted !== "inline" && wanted !== "summary" && wanted !== "log") { + // A typo must not silently escalate to the most-privileged posting mode. + throw new Error(`unknown AI_REVIEW_MODE "${requested}" (inline|summary|log)`); + } + const mode = wanted as Mode; + if (mode !== "log" && !hasToken) return { mode: "log", downgraded: true }; + return { mode, downgraded: false }; +} + +export interface EngineSpec { + argv: string[]; + promptVia: "stdin" | "arg"; +} + +/** + * The engine allowlist. docker-agent and claude are the tested pair; codex + * and copilot are best-effort (their headless flags move fast -- verify with + * `codex exec --help` / `copilot --help` and override with + * AI_REVIEW_ENGINE_CMD when they drift). + */ +export function engineSpec(engine: string, env: Record): EngineSpec { + const custom = env.AI_REVIEW_ENGINE_CMD; + if (custom && custom.trim().length > 0) { + // Through a shell, same as ai-review.sh, so pipes and quoting behave + // identically on both surfaces. + return { argv: ["sh", "-c", custom.trim()], promptVia: "stdin" }; + } + const maxTurns = env.AI_REVIEW_MAX_TURNS || "25"; + const config = env.AI_REVIEW_AGENT_CONFIG || ".gitlab/ai-review/review-agent.yaml"; + const safety = env.AI_REVIEW_ENGINE_FLAGS || "--safety restricted"; + switch (engine) { + case "claude": + return { + argv: ["claude", "-p", "--output-format", "json", "--max-turns", maxTurns, "--allowedTools", "Read Grep Glob"], + promptVia: "stdin", + }; + case "codex": + return { argv: ["codex", "exec", "--json"], promptVia: "stdin" }; + case "copilot": + return { argv: ["copilot", "-p"], promptVia: "arg" }; + case "docker-agent": + return { + argv: ["docker-agent", "run", "--exec", config, "--json", ...safety.split(/\s+/), "-"], + promptVia: "stdin", + }; + default: + throw new Error(`unknown AI_REVIEW_ENGINE "${engine}" (docker-agent|claude|codex|copilot)`); + } +} + +// --------------------------------------------------------------------------- +// Diff handling +// --------------------------------------------------------------------------- + +export interface FileDiff { + old_path: string; + new_path: string; + diff: string; +} + +export function truncateDiff( + files: FileDiff[], + maxFileLines: number, + maxTotalLines: number, +): { text: string; truncated: string[] } { + const truncated: string[] = []; + const parts: string[] = []; + let total = 0; + for (const f of files) { + if (total >= maxTotalLines) { + truncated.push(f.new_path); + continue; + } + const header = `diff --git a/${f.old_path} b/${f.new_path}\n--- a/${f.old_path}\n+++ b/${f.new_path}`; + const lines = f.diff.split("\n"); + let body = f.diff; + if (lines.length > maxFileLines) { + body = lines.slice(0, maxFileLines).join("\n"); + truncated.push(f.new_path); + } + const bodyLines = Math.min(lines.length, maxFileLines); + if (total + bodyLines > maxTotalLines) { + body = lines.slice(0, maxTotalLines - total).join("\n"); + truncated.push(f.new_path); + } + total += body.split("\n").length; + parts.push(`${header}\n${body}`); + } + return { text: parts.join("\n"), truncated: [...new Set(truncated)] }; +} + +export function buildPrompt( + rubric: string, + mr: { title: string; description: string }, + diffText: string, + truncated: string[], +): string { + const note = + truncated.length > 0 + ? `\nNote: the following files were truncated or omitted for size; say so in the summary: ${truncated.join(", ")}\n` + : ""; + return [ + rubric.trim(), + "", + "Respond with ONLY the findings-contract JSON object. No prose before or after it.", + note, + `Merge request title: ${mr.title}`, + `Merge request description:\n${mr.description || "(none)"}`, + "", + "Diff under review:", + "```diff", + diffText, + "```", + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// Rendering and sticky markers +// --------------------------------------------------------------------------- + +// The kind matters: summary notes and per-finding fallback notes both carry a +// marker, and GitLab lists notes newest-first. Matching on sha alone would let +// the newest fallback note be mistaken for the summary and get overwritten. +export type MarkerKind = "summary" | "finding"; + +const MARKER_RE = //; + +export function marker(sha: string, kind: MarkerKind): string { + return ``; +} + +export function markerShaOf(body: string): string | null { + const m = body.match(MARKER_RE); + return m ? m[1] : null; +} + +export function markerKindOf(body: string): string | null { + const m = body.match(MARKER_RE); + return m ? m[2] || null : null; +} + +const SEVERITY_LABEL: Record = { + blocker: "Blocker", + warning: "Warning", + nit: "Nit", +}; + +export function renderFinding(f: Finding, sha: string): string { + return `**[${SEVERITY_LABEL[f.severity]}]** ${f.title}\n\n${f.body}\n\n${marker(sha, "finding")}`; +} + +export function renderFallbackNote(f: Finding, sha: string): string { + const line = f.new_line !== null ? f.new_line : f.old_line; + return `**[${SEVERITY_LABEL[f.severity]}]** \`${f.path}:${line}\` -- ${f.title}\n\n${f.body}\n\n${marker(sha, "finding")}`; +} + +export function renderSummary(review: Review, truncated: string[], sha: string): string { + const counts: Record = { blocker: 0, warning: 0, nit: 0 }; + for (const f of review.findings) counts[f.severity] += 1; + const lines: string[] = ["## Automated code review", ""]; + if (review.findings.length === 0) { + lines.push("No findings. " + review.summary); + } else { + lines.push(review.summary, ""); + lines.push(`| Severity | Count |`, `|---|---|`); + (Object.keys(counts) as Severity[]).forEach((s) => { + if (counts[s] > 0) lines.push(`| ${SEVERITY_LABEL[s]} | ${counts[s]} |`); + }); + lines.push("", "| Finding | Location |", "|---|---|"); + for (const f of review.findings) { + const line = f.new_line !== null ? f.new_line : f.old_line; + lines.push(`| [${SEVERITY_LABEL[f.severity]}] ${f.title.replaceAll("|", "\\|")} | \`${f.path}:${line}\` |`); + } + } + if (truncated.length > 0) { + lines.push("", `Truncated for size and reviewed partially or not at all: ${truncated.map((t) => `\`${t}\``).join(", ")}`); + } + lines.push("", `Reviewed commit ${sha.slice(0, 12)}. Generated review; verify findings before acting on them.`, "", marker(sha, "summary")); + return lines.join("\n"); +} + +export function renderReport(review: Review): string { + const lines: string[] = [review.summary, ""]; + for (const f of review.findings) { + const line = f.new_line !== null ? f.new_line : f.old_line; + lines.push(`[${f.severity.toUpperCase()}] ${f.path}:${line} ${f.title}`); + lines.push(` ${f.body.replaceAll("\n", "\n ")}`, ""); + } + if (review.findings.length === 0) lines.push("No findings."); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Action planning +// --------------------------------------------------------------------------- + +export interface DiffRefs { + base_sha: string; + head_sha: string; + start_sha: string; +} + +export interface ExistingNote { + id: number; + body: string; +} + +export interface ExistingDiscussion { + id: string; + resolved: boolean; + body: string; +} + +export type Action = + | { type: "resolve_discussion"; discussion_id: string } + | { type: "create_discussion"; body: string; position: Record; fallback_body: string } + | { type: "create_note"; body: string } + | { type: "update_note"; note_id: number; body: string }; + +export function positionFor(f: Finding, refs: DiffRefs): Record { + const position: Record = { + position_type: "text", + base_sha: refs.base_sha, + head_sha: refs.head_sha, + start_sha: refs.start_sha, + new_path: f.path, + old_path: f.path, + }; + if (f.new_line !== null) position.new_line = f.new_line; + if (f.old_line !== null) position.old_line = f.old_line; + return position; +} + +export interface PlanContext { + mode: Mode; + headSha: string; + diffRefs: DiffRefs | null; + truncated: string[]; + existingNotes: ExistingNote[]; + existingDiscussions: ExistingDiscussion[]; +} + +export function planActions(review: Review, ctx: PlanContext): Action[] { + if (ctx.mode === "log") return []; + const actions: Action[] = []; + + if (ctx.mode === "inline") { + for (const d of ctx.existingDiscussions) { + const sha = markerShaOf(d.body); + if (sha !== null && sha !== ctx.headSha && !d.resolved) { + actions.push({ type: "resolve_discussion", discussion_id: d.id }); + } + } + if (ctx.diffRefs) { + for (const f of review.findings) { + actions.push({ + type: "create_discussion", + body: renderFinding(f, ctx.headSha), + position: positionFor(f, ctx.diffRefs), + fallback_body: renderFallbackNote(f, ctx.headSha), + }); + } + } + } + + const summaryBody = renderSummary(review, ctx.truncated, ctx.headSha); + const existing = ctx.existingNotes.find((n) => markerKindOf(n.body) === "summary"); + if (existing) { + actions.push({ type: "update_note", note_id: existing.id, body: summaryBody }); + } else { + actions.push({ type: "create_note", body: summaryBody }); + } + return actions; +} + +// --------------------------------------------------------------------------- +// GitLab API execution (injected for tests) +// --------------------------------------------------------------------------- + +export interface HttpResponse { + status: number; + body: unknown; +} + +export type HttpFn = (method: string, path: string, payload?: unknown) => Promise; + +export async function executeActions(actions: Action[], http: HttpFn): Promise<{ posted: number; fallbacks: number }> { + let posted = 0; + let fallbacks = 0; + for (const a of actions) { + switch (a.type) { + case "resolve_discussion": + await http("PUT", `/discussions/${a.discussion_id}`, { resolved: true }); + break; + case "create_discussion": { + const res = await http("POST", "/discussions", { body: a.body, position: a.position }); + if (res.status >= 200 && res.status < 300) { + posted += 1; + } else if (res.status === 400) { + // GitLab rejects positions it cannot map onto the diff (context + // lines, renames, and similar). Deliver the finding as a plain + // note rather than dropping it. + await http("POST", "/notes", { body: a.fallback_body }); + fallbacks += 1; + } else { + throw new Error(`create_discussion failed with HTTP ${res.status}`); + } + break; + } + case "create_note": + await http("POST", "/notes", { body: a.body }); + break; + case "update_note": + await http("PUT", `/notes/${a.note_id}`, { body: a.body }); + break; + } + } + return { posted, fallbacks }; +} + +function gitlabHttp(env: Record): HttpFn { + const base = `${env.CI_API_V4_URL}/projects/${encodeURIComponent(env.CI_PROJECT_ID as string)}/merge_requests/${env.CI_MERGE_REQUEST_IID}`; + const token = env.GITLAB_TOKEN as string; + return async (method, path, payload) => { + const res = await fetch(`${base}${path}`, { + method, + headers: { "PRIVATE-TOKEN": token, "Content-Type": "application/json" }, + body: payload === undefined ? undefined : JSON.stringify(payload), + }); + let body: unknown = null; + try { + body = await res.json(); + } catch { + body = null; + } + return { status: res.status, body }; + }; +} + +// --------------------------------------------------------------------------- +// CI entry point +// --------------------------------------------------------------------------- + +function fail(message: string, code: 1 | 2): never { + console.error(`post-mr-review: ${message}`); + process.exit(code); +} + +function readJsonFile(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")); +} + +function runEngine(spec: EngineSpec, prompt: string, env: Record): string { + // The engine must not see any GitLab token: it processes untrusted MR + // content, and posting is this script's job. CI_JOB_TOKEN goes too -- no + // engine needs it. The rest of the job environment (provider key included) + // necessarily remains reachable. + const STRIPPED = ["GITLAB_TOKEN", "GITLAB_ACCESS_TOKEN", "CI_JOB_TOKEN"]; + const childEnv: Record = {}; + for (const [k, v] of Object.entries(env)) { + if (v !== undefined && !STRIPPED.includes(k)) childEnv[k] = v; + } + childEnv.TELEMETRY_ENABLED = childEnv.TELEMETRY_ENABLED || "false"; + const argv = [...spec.argv]; + if (spec.promptVia === "arg") argv.push(prompt); + const result = spawnSync(argv[0], argv.slice(1), { + input: spec.promptVia === "stdin" ? prompt : undefined, + env: childEnv, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.error) fail(`could not run engine "${argv[0]}": ${result.error.message}`, 1); + const out = `${result.stdout || ""}\n${result.stderr || ""}`; + if (typeof result.status === "number" && result.status !== 0 && !extractReview(result.stdout || "")) { + console.error(out); + fail(`engine exited ${result.status} without usable output`, 1); + } + return result.stdout || ""; +} + +function localDiff(baseSha: string): FileDiff[] { + const result = spawnSync("git", ["diff", "--no-color", `${baseSha}...HEAD`], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + fail(`git diff against ${baseSha} failed (shallow clone? set GIT_DEPTH: "0"): ${result.stderr}`, 2); + } + // Split the unified diff back into per-file chunks so truncation budgets + // apply per file, same as the API path. + const files: FileDiff[] = []; + for (const chunk of (result.stdout || "").split(/^diff --git /m).slice(1)) { + const m = chunk.match(/^a\/(\S+) b\/(\S+)/); + if (!m) continue; + const body = chunk.split("\n").slice(1).join("\n"); + files.push({ old_path: m[1], new_path: m[2], diff: body }); + } + return files; +} + +async function main(): Promise { + const env = process.env; + const dryRun = env.AI_REVIEW_DRY_RUN === "1"; + const artifactsDir = env.AI_REVIEW_ARTIFACTS || "ai-review-artifacts"; + const hasToken = Boolean(env.GITLAB_TOKEN); + let resolved: { mode: Mode; downgraded: boolean }; + try { + resolved = resolveMode(env.AI_REVIEW_MODE, hasToken); + } catch (err) { + fail(err instanceof Error ? err.message : String(err), 2); + } + const { mode, downgraded } = resolved; + if (downgraded) { + console.error( + `post-mr-review: AI_REVIEW_MODE=${env.AI_REVIEW_MODE || "inline"} requires GITLAB_TOKEN ` + + `(CI_JOB_TOKEN cannot create MR notes); falling back to log mode.`, + ); + } + + const iid = env.CI_MERGE_REQUEST_IID; + if (!iid && !dryRun) fail("not a merge request pipeline (CI_MERGE_REQUEST_IID unset)", 2); + + const headSha = env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA || env.CI_COMMIT_SHA || "unknown"; + const http: HttpFn = dryRun + ? async (method, path, payload) => { + console.log(JSON.stringify({ planned: { method, path, payload } })); + return { status: 200, body: null }; + } + : gitlabHttp(env); + + // Gather MR state (token modes only). + let diffRefs: DiffRefs | null = null; + let files: FileDiff[] = []; + let mrTitle = env.CI_MERGE_REQUEST_TITLE || ""; + let mrDescription = env.CI_MERGE_REQUEST_DESCRIPTION || ""; + let existingNotes: ExistingNote[] = []; + let existingDiscussions: ExistingDiscussion[] = []; + + if (mode !== "log") { + const mrRaw = dryRun && env.AI_REVIEW_FIXTURE_CHANGES + ? readJsonFile(env.AI_REVIEW_FIXTURE_CHANGES) + : (await http("GET", "/changes")).body; + const mr = (mrRaw || {}) as { + title?: string; + description?: string; + draft?: boolean; + work_in_progress?: boolean; + diff_refs?: DiffRefs; + changes?: FileDiff[]; + }; + if (mr.draft === true || mr.work_in_progress === true) { + console.log("post-mr-review: draft merge request, skipping review."); + return; + } + mrTitle = mr.title || mrTitle; + mrDescription = mr.description || mrDescription; + diffRefs = mr.diff_refs || null; + files = mr.changes || []; + + const notesRaw = dryRun && env.AI_REVIEW_FIXTURE_NOTES + ? readJsonFile(env.AI_REVIEW_FIXTURE_NOTES) + : (await http("GET", "/notes?per_page=100")).body; + existingNotes = ((notesRaw as { id: number; body: string }[]) || []).map((n) => ({ id: n.id, body: n.body })); + + const discussionsRaw = dryRun && env.AI_REVIEW_FIXTURE_DISCUSSIONS + ? readJsonFile(env.AI_REVIEW_FIXTURE_DISCUSSIONS) + : (await http("GET", "/discussions?per_page=100")).body; + existingDiscussions = ((discussionsRaw as { id: string; notes?: { body: string; resolved?: boolean }[] }[]) || []) + .filter((d) => (d.notes || []).length > 0) + .map((d) => ({ + id: d.id, + resolved: Boolean(d.notes && d.notes[0].resolved), + body: d.notes && d.notes[0] ? d.notes[0].body : "", + })); + + // Same head already reviewed: a pipeline retry, not a new push. + const summaryNote = existingNotes.find((n) => markerKindOf(n.body) === "summary"); + if (summaryNote && markerShaOf(summaryNote.body) === headSha) { + console.log(`post-mr-review: head ${headSha.slice(0, 12)} already reviewed, nothing to do.`); + return; + } + } else { + const baseSha = env.CI_MERGE_REQUEST_DIFF_BASE_SHA; + if (!baseSha && !dryRun) fail("log mode needs CI_MERGE_REQUEST_DIFF_BASE_SHA for a local diff", 2); + if (baseSha) files = localDiff(baseSha); + } + + const maxFileLines = Number(env.AI_REVIEW_MAX_FILE_LINES || 1500); + const maxTotalLines = Number(env.AI_REVIEW_MAX_DIFF_LINES || 6000); + const { text: diffText, truncated } = truncateDiff(files, maxFileLines, maxTotalLines); + if (diffText.trim().length === 0 && !env.AI_REVIEW_FIXTURE_OUTPUT) { + console.log("post-mr-review: empty diff, nothing to review."); + return; + } + + const rubricPath = env.AI_REVIEW_RUBRIC || ".gitlab/ai-review/review-rubric.md"; + let rubric = ""; + try { + rubric = readFileSync(rubricPath, "utf8"); + } catch { + // A saved engine output makes the prompt (and so the rubric) unused. + if (!env.AI_REVIEW_FIXTURE_OUTPUT) fail(`rubric not found at ${rubricPath}`, 2); + } + const prompt = buildPrompt(rubric, { title: mrTitle, description: mrDescription }, diffText, truncated); + + mkdirSync(artifactsDir, { recursive: true }); + let rawOutput: string; + if (env.AI_REVIEW_FIXTURE_OUTPUT) { + rawOutput = readFileSync(env.AI_REVIEW_FIXTURE_OUTPUT, "utf8"); + } else { + const engine = env.AI_REVIEW_ENGINE || "docker-agent"; + rawOutput = runEngine(engineSpec(engine, env), prompt, env); + } + writeFileSync(join(artifactsDir, "transcript.ndjson"), rawOutput); + + const review = extractReview(rawOutput); + if (!review) { + fail("engine output did not contain a valid findings-contract object (transcript saved to artifacts)", 1); + } + writeFileSync(join(artifactsDir, "findings.json"), JSON.stringify(review, null, 2)); + writeFileSync(join(artifactsDir, "review.md"), renderSummary(review, truncated, headSha)); + + if (mode === "log") { + console.log(renderReport(review)); + return; + } + + const actions = planActions(review, { + mode, + headSha, + diffRefs, + truncated, + existingNotes, + existingDiscussions, + }); + const { posted, fallbacks } = await executeActions(actions, http); + console.log( + `post-mr-review: delivered ${review.findings.length} finding(s) in ${mode} mode` + + (mode === "inline" ? ` (${posted} positioned, ${fallbacks} as plain notes)` : "") + + ".", + ); +} + +// --------------------------------------------------------------------------- +// --extract: parse a saved engine output file (used by ai-review.sh) +// --------------------------------------------------------------------------- + +function extractCli(args: string[]): void { + const file = args.find((a) => !a.startsWith("--")); + if (!file) fail("--extract needs a file argument", 2); + const review = extractReview(readFileSync(file, "utf8")); + if (!review) fail("no valid findings-contract object in engine output", 1); + if (args.includes("--report")) { + console.log(renderReport(review)); + } else { + console.log(JSON.stringify(review, null, 2)); + } + if (args.includes("--blocking") && review.findings.some((f) => f.severity === "blocker")) { + process.exit(1); + } +} + +if (import.meta.main) { + const args = process.argv.slice(2); + if (args[0] === "--extract") { + extractCli(args.slice(1)); + } else { + main().catch((err) => fail(err instanceof Error ? err.message : String(err), 1)); + } +} diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/claude-output.json b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/claude-output.json new file mode 100644 index 0000000..2b8a8df --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/claude-output.json @@ -0,0 +1,9 @@ +{ + "type": "result", + "subtype": "success", + "is_error": false, + "duration_ms": 48210, + "num_turns": 6, + "result": "{\"summary\":\"One concurrency defect in the retry queue; everything else in the diff is sound.\",\"findings\":[{\"path\":\"src/queue/retry.ts\",\"new_line\":88,\"old_line\":null,\"severity\":\"warning\",\"title\":\"Retry loop has no upper bound\",\"body\":\"`while (!done)` re-enqueues on every failure with no attempt counter or backoff. A permanently failing job spins forever. Bound the attempts and surface the terminal failure.\"}]}", + "session_id": "3f1c2a44-example" +} diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json new file mode 100644 index 0000000..d2836ca --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json @@ -0,0 +1,20 @@ +[ + { + "id": "d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0", + "notes": [ + { + "body": "**[Warning]** Stale finding from the previous revision\n\nDetail.\n\n", + "resolved": false + } + ] + }, + { + "id": "e2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", + "notes": [ + { + "body": "Human-opened thread about naming.", + "resolved": false + } + ] + } +] diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes.json b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes.json new file mode 100644 index 0000000..fee2ddd --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes.json @@ -0,0 +1,24 @@ +{ + "iid": 7, + "title": "Add currency handling to the charge path", + "description": "Extends charge() to accept a currency code and drops the legacy refund guard.", + "draft": false, + "work_in_progress": false, + "diff_refs": { + "base_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "head_sha": "beefbeefbeefbeefbeefbeefbeefbeefbeefbeef", + "start_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" + }, + "changes": [ + { + "old_path": "src/payments/charge.ts", + "new_path": "src/payments/charge.ts", + "diff": "@@ -38,6 +38,10 @@ export async function charge(req: Request) {\n const amount = req.body.amount;\n+ const currency = req.body.currency;\n+ const entry = { amount, currency };\n+ await ledger.post(entry);\n+ return entry;\n }" + }, + { + "old_path": "src/payments/refund.ts", + "new_path": "src/payments/refund.ts", + "diff": "@@ -14,8 +14,6 @@ export async function refund(id: string) {\n const invoice = await invoices.get(id);\n- if (await ledger.seen(idempotencyKey(id))) {\n- return ledger.lastResult(id);\n- }\n return ledger.credit(invoice);\n }" + } + ] +} diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json new file mode 100644 index 0000000..d4bced4 --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json @@ -0,0 +1,14 @@ +[ + { + "id": 60, + "body": "**[Warning]** `src/payments/refund.ts:17` -- Fallback finding from the previous revision\n\nPosted as a plain note because GitLab rejected its diff position.\n\n" + }, + { + "id": 55, + "body": "Looks reasonable overall, one question about the ledger call inline." + }, + { + "id": 101, + "body": "## Automated code review\n\nPrevious review of an earlier revision.\n\nReviewed commit abc123abc123.\n\n" + } +] diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-without-marker.json b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-without-marker.json new file mode 100644 index 0000000..9d4d82c --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-without-marker.json @@ -0,0 +1,6 @@ +[ + { + "id": 55, + "body": "Looks reasonable overall, one question about the ledger call inline." + } +] diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-engine.sh b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-engine.sh new file mode 100644 index 0000000..ea7f07d --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-engine.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# A fake review engine for harness tests: consumes the prompt on stdin and +# prints a findings-contract object containing one blocker. +cat >/dev/null +printf '%s\n' '{"summary":"Stub engine review.","findings":[{"path":"src/app.ts","new_line":10,"old_line":null,"severity":"blocker","title":"Stub blocker finding","body":"Emitted by the test stub."}]}' diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson new file mode 100644 index 0000000..3b8ca34 --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson @@ -0,0 +1,4 @@ +{"type":"session_start","agent":"root","model":"anthropic/claude-opus-5"} +{"type":"message","role":"assistant","content":"Here is my review of the diff: the charge path has a validation gap around line 42."} +{"type":"structured_output","content":"{\"summary\":\"Truncated before the findings array closed\",\"findings\":[{\"path\":\"src/payments/charge.ts\",\"new_line\":42"} +{"type":"error","message":"model stream ended unexpectedly"} diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript.ndjson b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript.ndjson new file mode 100644 index 0000000..57cff3b --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript.ndjson @@ -0,0 +1,6 @@ +{"type":"session_start","agent":"root","model":"anthropic/claude-opus-5"} +{"type":"message","role":"assistant","content":"Reading the changed payment paths before judging the diff."} +{"type":"tool_call","name":"read_file","arguments":{"path":"src/payments/charge.ts"}} +{"type":"tool_result","name":"read_file","status":"ok"} +{"type":"structured_output","content":"{\"summary\":\"Two payment-path defects; the schema migration itself looks safe.\",\"findings\":[{\"path\":\"src/payments/charge.ts\",\"new_line\":42,\"old_line\":null,\"severity\":\"blocker\",\"title\":\"Charge amount accepted without currency validation\",\"body\":\"`charge()` trusts `req.body.currency`; an unknown code is passed to the ledger and recorded as USD. Validate against SUPPORTED_CURRENCIES before posting.\"},{\"path\":\"src/payments/refund.ts\",\"new_line\":null,\"old_line\":17,\"severity\":\"warning\",\"title\":\"Idempotency check removed with no replacement\",\"body\":\"The deleted guard was the only duplicate-refund protection; retries now double-refund. Restore the idempotency key check or dedupe at the ledger.\"}]}"} +{"type":"session_end","turns":4} diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh b/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh new file mode 100644 index 0000000..661c1b6 --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh @@ -0,0 +1,206 @@ +#!/bin/sh +# ============================================================================= +# Tests for post-mr-review.ts and ai-review.sh. +# +# Fixture-driven throughout: no network, no GitLab, no real engines. The +# wrapper's pure functions are covered by unit.test.ts under `bun test`; this +# suite covers the CLI contract, the dry-run action planning, and the shell +# harness with a stub engine. scripts/verify-all.sh discovers and runs this +# file, so a regression fails the repository gate. +# ============================================================================= + +set -u + +SUITE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PLUGIN_DIR=$(CDPATH= cd -- "$SUITE_DIR/../../.." && pwd) +WRAPPER="$PLUGIN_DIR/scripts/post-mr-review.ts" +HARNESS="$PLUGIN_DIR/scripts/ai-review.sh" +FIXTURES="$SUITE_DIR/fixtures" + +passed=0 +failed=0 + +pass() { passed=$((passed + 1)); printf ' ok %s\n' "$1"; } +fail() { failed=$((failed + 1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; } + +echo "post-mr-review.ts / ai-review.sh" + +for f in "$WRAPPER" "$HARNESS"; do + if [ ! -f "$f" ]; then + fail "exists: $f" + echo " $passed passed, $failed failed" + exit 1 + fi +done +pass "wrapper and harness exist" + +if ! command -v bun >/dev/null 2>&1; then + echo " note: bun not installed; skipping execution tests" + echo " $passed passed, $failed failed" + [ "$failed" -eq 0 ] || exit 1 + exit 0 +fi + +TMPDIR_LOCAL="$PLUGIN_DIR/../../.tmp" +mkdir -p "$TMPDIR_LOCAL" +WORK=$(mktemp -d "$TMPDIR_LOCAL/mr-review-tests.XXXXXX") || exit 1 +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT + +# --- unit tests --------------------------------------------------------------- + +if bun test "$SUITE_DIR/unit.test.ts" >"$WORK/unit.log" 2>&1; then + pass "unit.test.ts suite" +else + fail "unit.test.ts suite" "$(tail -5 "$WORK/unit.log")" +fi + +# --- --extract contract -------------------------------------------------------- + +out=$(bun "$WRAPPER" --extract "$FIXTURES/transcript.ndjson" 2>&1) +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q '"findings"'; then + pass "--extract parses a docker-agent transcript" +else + fail "--extract parses a docker-agent transcript" "$out" +fi + +out=$(bun "$WRAPPER" --extract "$FIXTURES/claude-output.json" --report 2>&1) +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Retry loop has no upper bound'; then + pass "--extract --report renders a claude result" +else + fail "--extract --report renders a claude result" "$out" +fi + +if bun "$WRAPPER" --extract "$FIXTURES/transcript.ndjson" --report --blocking >/dev/null 2>&1; then + fail "--blocking exits 1 on a blocker" +else + pass "--blocking exits 1 on a blocker" +fi + +if bun "$WRAPPER" --extract "$FIXTURES/claude-output.json" --blocking >/dev/null 2>&1; then + pass "--blocking exits 0 with no blocker" +else + fail "--blocking exits 0 with no blocker" +fi + +if bun "$WRAPPER" --extract "$FIXTURES/transcript-malformed.ndjson" >/dev/null 2>&1; then + fail "malformed transcript exits non-zero" +else + pass "malformed transcript exits non-zero" +fi + +# --- dry-run: inline mode over fixtures ---------------------------------------- + +run_dry() { + # $1 head sha, $2 mode, $3 token ("" for none), remaining env via caller + AI_REVIEW_DRY_RUN=1 \ + GITLAB_TOKEN="$3" \ + AI_REVIEW_MODE="$2" \ + CI_API_V4_URL="https://gitlab.example.com/api/v4" \ + CI_PROJECT_ID="123" \ + CI_MERGE_REQUEST_IID="7" \ + CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="$1" \ + AI_REVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes.json" \ + AI_REVIEW_FIXTURE_NOTES="$FIXTURES/notes-with-marker.json" \ + AI_REVIEW_FIXTURE_DISCUSSIONS="$FIXTURES/discussions-stale.json" \ + AI_REVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ + AI_REVIEW_ARTIFACTS="$WORK/artifacts" \ + bun "$WRAPPER" 2>"$WORK/dry-stderr.txt" +} + +HEAD_SHA="beefbeefbeefbeefbeefbeefbeefbeefbeefbeef" + +out=$(run_dry "$HEAD_SHA" inline fake-token) +status=$? + +if [ "$status" -eq 0 ]; then + pass "dry-run inline exits 0" +else + fail "dry-run inline exits 0" "exit $status: $(cat "$WORK/dry-stderr.txt")" +fi + +count=$(printf '%s\n' "$out" | grep -c '"path":"/discussions"') +if [ "$count" -eq 2 ]; then + pass "dry-run inline plans one discussion per finding" +else + fail "dry-run inline plans one discussion per finding" "saw $count: $out" +fi + +if printf '%s' "$out" | grep -q '"path":"/discussions/d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0"'; then + pass "dry-run inline resolves the stale bot thread" +else + fail "dry-run inline resolves the stale bot thread" "$out" +fi + +if printf '%s' "$out" | grep -q '"path":"/notes/101"'; then + pass "dry-run inline updates the sticky summary note" +else + fail "dry-run inline updates the sticky summary note" "$out" +fi + +if printf '%s' "$out" | grep -q '"new_line":42'; then + pass "dry-run inline carries the diff position" +else + fail "dry-run inline carries the diff position" "$out" +fi + +if [ -f "$WORK/artifacts/findings.json" ] && [ -f "$WORK/artifacts/review.md" ]; then + pass "dry-run writes findings.json and review.md artifacts" +else + fail "dry-run writes findings.json and review.md artifacts" +fi + +# --- dry-run: pipeline retry is a no-op ---------------------------------------- + +out=$(run_dry "abc123abc123" inline fake-token) +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'already reviewed'; then + pass "same-head retry is a no-op" +else + fail "same-head retry is a no-op" "$out" +fi + +# --- dry-run: no token downgrades to log mode ---------------------------------- + +out=$(run_dry "$HEAD_SHA" inline "") +if [ $? -eq 0 ] && + grep -q 'falling back to log mode' "$WORK/dry-stderr.txt" && + printf '%s' "$out" | grep -q '\[BLOCKER\]'; then + pass "missing token downgrades inline to log with a notice" +else + fail "missing token downgrades inline to log with a notice" "$out $(cat "$WORK/dry-stderr.txt")" +fi + +# --- harness: stub engine ------------------------------------------------------- + +printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n+changed\n' >"$WORK/stub.diff" + +out=$(TMPDIR="$WORK" \ + AI_REVIEW_DIFF_FILE="$WORK/stub.diff" \ + AI_REVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ + sh "$HARNESS" 2>&1) +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Stub blocker finding'; then + pass "harness reports stub engine findings" +else + fail "harness reports stub engine findings" "$out" +fi + +if TMPDIR="$WORK" \ + AI_REVIEW_DIFF_FILE="$WORK/stub.diff" \ + AI_REVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ + AI_REVIEW_BLOCKING=1 \ + sh "$HARNESS" >/dev/null 2>&1; then + fail "harness blocking mode exits 1 on a blocker" +else + pass "harness blocking mode exits 1 on a blocker" +fi + +: >"$WORK/empty.diff" +out=$(TMPDIR="$WORK" AI_REVIEW_DIFF_FILE="$WORK/empty.diff" sh "$HARNESS" 2>&1) +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'empty diff'; then + pass "harness exits 0 on an empty diff" +else + fail "harness exits 0 on an empty diff" "$out" +fi + +echo " $passed passed, $failed failed" +[ "$failed" -eq 0 ] || exit 1 diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts b/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts new file mode 100644 index 0000000..0d788fa --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests for post-mr-review.ts pure functions. No network, no engines: + * everything runs against fixtures and injected stubs. run-tests.sh drives + * this file with `bun test` and covers the CLI surface separately. + */ + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + type Action, + type DiffRefs, + type Finding, + executeActions, + extractReview, + markerKindOf, + markerShaOf, + planActions, + positionFor, + renderSummary, + resolveMode, + truncateDiff, + validateReview, +} from "../../post-mr-review.ts"; + +const FIXTURES = join(import.meta.dir, "fixtures"); +const fixture = (name: string): string => readFileSync(join(FIXTURES, name), "utf8"); + +const REFS: DiffRefs = { + base_sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + head_sha: "beefbeefbeefbeefbeefbeefbeefbeefbeefbeef", + start_sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", +}; + +describe("extractReview", () => { + test("parses a bare findings-contract object", () => { + const review = extractReview('{"summary":"ok","findings":[]}'); + expect(review).not.toBeNull(); + expect(review!.findings).toHaveLength(0); + }); + + test("finds structured output nested in a docker-agent ndjson stream", () => { + const review = extractReview(fixture("transcript.ndjson")); + expect(review).not.toBeNull(); + expect(review!.findings).toHaveLength(2); + expect(review!.findings[0].severity).toBe("blocker"); + expect(review!.findings[1].old_line).toBe(17); + }); + + test("unwraps a claude --output-format json result", () => { + const review = extractReview(fixture("claude-output.json")); + expect(review).not.toBeNull(); + expect(review!.findings[0].path).toBe("src/queue/retry.ts"); + }); + + test("reads a fenced json block inside prose", () => { + const raw = 'Here is the review:\n```json\n{"summary":"clean","findings":[]}\n```\nDone.'; + expect(extractReview(raw)).not.toBeNull(); + }); + + test("returns null for a malformed transcript", () => { + expect(extractReview(fixture("transcript-malformed.ndjson"))).toBeNull(); + }); +}); + +describe("validateReview", () => { + test("rejects an unknown severity", () => { + const { review, errors } = validateReview({ + summary: "s", + findings: [{ path: "a.ts", new_line: 1, old_line: null, severity: "critical", title: "t", body: "b" }], + }); + expect(review).toBeNull(); + expect(errors.join(" ")).toContain("severity"); + }); + + test("rejects a finding with neither line", () => { + const { review } = validateReview({ + summary: "s", + findings: [{ path: "a.ts", new_line: null, old_line: null, severity: "nit", title: "t", body: "b" }], + }); + expect(review).toBeNull(); + }); + + test("accepts an empty findings array as a clean review", () => { + expect(validateReview({ summary: "clean", findings: [] }).review).not.toBeNull(); + }); +}); + +describe("resolveMode", () => { + test("keeps inline when a token is present", () => { + expect(resolveMode("inline", true)).toEqual({ mode: "inline", downgraded: false }); + }); + test("downgrades comment modes to log without a token", () => { + expect(resolveMode("inline", false)).toEqual({ mode: "log", downgraded: true }); + expect(resolveMode("summary", false)).toEqual({ mode: "log", downgraded: true }); + }); + test("log mode never needs a token", () => { + expect(resolveMode("log", false)).toEqual({ mode: "log", downgraded: false }); + }); + test("defaults to inline", () => { + expect(resolveMode(undefined, true).mode).toBe("inline"); + }); + test("rejects unknown modes instead of escalating to inline", () => { + expect(() => resolveMode("summry", true)).toThrow("unknown AI_REVIEW_MODE"); + }); +}); + +describe("truncateDiff", () => { + const file = (path: string, lines: number) => ({ + old_path: path, + new_path: path, + diff: Array.from({ length: lines }, (_, i) => `+line ${i}`).join("\n"), + }); + + test("keeps small diffs whole", () => { + const { text, truncated } = truncateDiff([file("a.ts", 10)], 100, 1000); + expect(truncated).toHaveLength(0); + expect(text).toContain("+line 9"); + }); + + test("caps a single oversized file and reports it", () => { + const { text, truncated } = truncateDiff([file("big.ts", 500)], 100, 1000); + expect(truncated).toEqual(["big.ts"]); + expect(text).not.toContain("+line 400"); + }); + + test("drops files past the total budget and reports them", () => { + const { truncated } = truncateDiff([file("a.ts", 900), file("b.ts", 900)], 1000, 1000); + expect(truncated).toContain("b.ts"); + }); +}); + +describe("positions and markers", () => { + const added: Finding = { path: "a.ts", new_line: 42, old_line: null, severity: "blocker", title: "t", body: "b" }; + const deleted: Finding = { path: "d.ts", new_line: null, old_line: 17, severity: "warning", title: "t", body: "b" }; + + test("an added line maps to new_line only", () => { + const p = positionFor(added, REFS); + expect(p.new_line).toBe(42); + expect(p.old_line).toBeUndefined(); + expect(p.base_sha).toBe(REFS.base_sha); + }); + + test("a deleted line maps to old_line only", () => { + const p = positionFor(deleted, REFS); + expect(p.old_line).toBe(17); + expect(p.new_line).toBeUndefined(); + }); + + test("the summary carries a typed marker the parser reads back", () => { + const body = renderSummary({ summary: "s", findings: [] }, [], REFS.head_sha); + expect(markerShaOf(body)).toBe(REFS.head_sha); + expect(markerKindOf(body)).toBe("summary"); + }); + + test("markers without a kind still yield their sha", () => { + expect(markerShaOf("")).toBe("abc123abc123"); + expect(markerKindOf("")).toBeNull(); + }); +}); + +describe("planActions", () => { + const review = { + summary: "s", + findings: [ + { path: "a.ts", new_line: 1, old_line: null, severity: "blocker", title: "t1", body: "b1" }, + { path: "b.ts", new_line: 2, old_line: null, severity: "nit", title: "t2", body: "b2" }, + ] as Finding[], + }; + const notesWithMarker = JSON.parse(fixture("notes-with-marker.json")) as { id: number; body: string }[]; + const staleDiscussions = (JSON.parse(fixture("discussions-stale.json")) as { + id: string; + notes: { body: string; resolved: boolean }[]; + }[]).map((d) => ({ id: d.id, resolved: d.notes[0].resolved, body: d.notes[0].body })); + + const ctx = { + headSha: REFS.head_sha, + diffRefs: REFS, + truncated: [] as string[], + existingNotes: notesWithMarker, + existingDiscussions: staleDiscussions, + }; + + test("inline: resolves stale bot threads before posting, then updates the sticky note", () => { + const actions = planActions(review, { ...ctx, mode: "inline" }); + const types = actions.map((a) => a.type); + expect(types).toEqual(["resolve_discussion", "create_discussion", "create_discussion", "update_note"]); + expect((actions[0] as Extract).discussion_id).toBe( + "d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0", + ); + }); + + test("inline: never touches human threads", () => { + const actions = planActions(review, { ...ctx, mode: "inline" }); + const resolved = actions.filter((a) => a.type === "resolve_discussion"); + expect(resolved).toHaveLength(1); + }); + + test("summary: a single note action, updated in place when the marker exists", () => { + const actions = planActions(review, { ...ctx, mode: "summary" }); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe("update_note"); + }); + + test("the newest fallback note never captures the summary update", () => { + // The notes fixture lists a kind=finding fallback note (id 60) before the + // kind=summary note (id 101), matching GitLab's newest-first ordering. + const actions = planActions(review, { ...ctx, mode: "summary" }); + expect((actions[0] as Extract).note_id).toBe(101); + }); + + test("summary: creates the note when no marker exists yet", () => { + const bare = JSON.parse(fixture("notes-without-marker.json")) as { id: number; body: string }[]; + const actions = planActions(review, { ...ctx, mode: "summary", existingNotes: bare }); + expect(actions[0].type).toBe("create_note"); + }); + + test("log: plans nothing", () => { + expect(planActions(review, { ...ctx, mode: "log" })).toHaveLength(0); + }); +}); + +describe("executeActions", () => { + test("degrades a rejected position to a plain note", async () => { + const calls: { method: string; path: string }[] = []; + let discussionCalls = 0; + const http = async (method: string, path: string) => { + calls.push({ method, path }); + if (path === "/discussions" && method === "POST") { + discussionCalls += 1; + return { status: discussionCalls === 1 ? 400 : 201, body: null }; + } + return { status: 200, body: null }; + }; + const actions: Action[] = [ + { type: "create_discussion", body: "one", position: {}, fallback_body: "one-fallback" }, + { type: "create_discussion", body: "two", position: {}, fallback_body: "two-fallback" }, + { type: "create_note", body: "summary" }, + ]; + const { posted, fallbacks } = await executeActions(actions, http); + expect(posted).toBe(1); + expect(fallbacks).toBe(1); + expect(calls.filter((c) => c.path === "/notes")).toHaveLength(2); + }); + + test("surfaces non-400 failures instead of swallowing them", async () => { + const http = async () => ({ status: 500, body: null }); + const actions: Action[] = [{ type: "create_discussion", body: "x", position: {}, fallback_body: "y" }]; + await expect(executeActions(actions, http)).rejects.toThrow("500"); + }); +}); diff --git a/plugins/act-gitlab-ci/skills/ci-auth-providers/SKILL.md b/plugins/act-gitlab-ci/skills/ci-auth-providers/SKILL.md index 4a11386..3233c01 100644 --- a/plugins/act-gitlab-ci/skills/ci-auth-providers/SKILL.md +++ b/plugins/act-gitlab-ci/skills/ci-auth-providers/SKILL.md @@ -59,7 +59,7 @@ instance URL. > conditions on project and ref can be assumed from any pipeline in the instance. Restrict it to the > specific project and to protected refs. -Bedrock model IDs carry region-specific prefixes, for example `us.anthropic.claude-sonnet-4-6`. +Bedrock model IDs carry region-specific prefixes, for example `us.anthropic.claude-opus-5`. ## Google Cloud over Workload Identity Federation diff --git a/plugins/act-gitlab-ci/skills/claude-code-ci-jobs/examples/bedrock-oidc.yml b/plugins/act-gitlab-ci/skills/claude-code-ci-jobs/examples/bedrock-oidc.yml index 8f29d9d..d4037b8 100644 --- a/plugins/act-gitlab-ci/skills/claude-code-ci-jobs/examples/bedrock-oidc.yml +++ b/plugins/act-gitlab-ci/skills/claude-code-ci-jobs/examples/bedrock-oidc.yml @@ -58,7 +58,7 @@ claude-bedrock: CLAUDE_CODE_USE_BEDROCK: "1" # Bedrock model IDs carry region-specific prefixes, for example -# us.anthropic.claude-sonnet-4-6. Pass the model through your job configuration +# us.anthropic.claude-opus-5. Pass the model through your job configuration # or the prompt if the workflow supports it. # # Note: the paths above are inside the ephemeral job container, which is diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md b/plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md new file mode 100644 index 0000000..13d0edb --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md @@ -0,0 +1,103 @@ +--- +name: mr-review-agent +description: This skill should be used when the user asks to "review merge requests automatically", "set up AI code review", "run a review agent in the pipeline", "post review comments on MRs", "review every MR", or mentions docker-agent, AI_REVIEW_MODE, AI_REVIEW_ENGINE, GITLAB_TOKEN for review comments, post-mr-review.ts, ai-review.sh, review-agent.yaml, review-rubric.md, a pre-push AI review hook, inline MR discussions from CI, or Copilot code review instructions. Covers the review contract, the engine matrix, delivery modes, tokens, and re-push semantics. +--- + +# Automated merge-request review + +An engine-agnostic code review that runs on every submitted merge request, plus the same review as +a git hook, an in-session command, or GitHub Copilot's native reviewer. + +## The contract, not the engine, is the product + +Everything is built around two stable artifacts in this skill's `references/` and shipped alongside +the scripts: + +- **`references/review-rubric.md`** -- what a reviewer looks for, the severity scale, and the + findings JSON contract `{summary, findings: [{path, new_line, old_line, severity, title, body}]}`. +- **Deterministic delivery scripts** -- `scripts/post-mr-review.ts` (CI, posts to GitLab) and + `scripts/ai-review.sh` (local, prints a report). The engine only ever produces findings; the + scripts do everything with side effects. + +Engines are swappable via `AI_REVIEW_ENGINE`: + +| Engine | Runs | Status | +|---|---|---| +| `docker-agent` (default) | Pinned standalone binary; provider-agnostic `model:` in `review-agent.yaml`. No Docker daemon involved. | Tested pair | +| `claude` | `claude -p` headless with read-only tools | Tested pair | +| `codex` | `codex exec` | Best effort; verify flags per version | +| `copilot` | Copilot CLI | Best effort; verify flags per version | + +Any other engine: set `AI_REVIEW_ENGINE_CMD` to a command that reads the prompt on stdin and +prints the findings JSON. + +## Four surfaces + +| Surface | Entry point | Setup | +|---|---|---| +| GitLab CI on every MR | `examples/mr-review-job.yml` running `post-mr-review.ts` | `/act-gitlab-ci:setup-mr-review gitlab-ci` | +| Git hooks / scripts | `scripts/ai-review.sh`, `examples/git-hook-pre-push.sh` | `/act-gitlab-ci:setup-mr-review git-hook` | +| In-session, any host | the `review-mr` command | installed with the plugin | +| GitHub Copilot native review | `examples/copilot-code-review.instructions.md` | `/act-gitlab-ci:setup-mr-review copilot` | + +The CI scripts are **copied into the target repository** (conventionally `.gitlab/ai-review/`) +because a CI job cannot resolve plugin paths. Re-run the setup command to pick up plugin updates. + +## Delivery modes (GitLab CI) + +`AI_REVIEW_MODE` selects how findings reach the MR; see `references/review-modes.md` for detail. + +| Mode | Needs | Result | +|---|---|---| +| `inline` (default) | `GITLAB_TOKEN` | One positioned discussion per finding plus a sticky summary note. Positions GitLab rejects degrade to plain notes. On re-push, stale bot threads are resolved and the summary updates in place. | +| `summary` | `GITLAB_TOKEN` | The sticky summary note only. | +| `log` | nothing | Job log plus `ai-review-artifacts/`. Automatic fallback when `GITLAB_TOKEN` is unset. | + +## Tokens: when CI_JOB_TOKEN is not enough + +`CI_JOB_TOKEN` cannot create notes or discussions on a merge request, which is exactly what +`inline` and `summary` modes do. Those modes need `GITLAB_TOKEN`: a project access token with +`api` scope and Developer role, stored masked. + +This is the documented exception to the `claude-code-ci-jobs` guidance to prefer `CI_JOB_TOKEN`. +That guidance stands wherever the job token's permissions suffice (cloning, package registries, +trigger tokens); posting review comments is a case where they do not. Without a project access +token, run `log` mode -- it needs no token at all. + +> [!CAUTION] +> The diff under review is untrusted input to the model. Keep engine toolsets read-only (the +> shipped `review-agent.yaml` already is), never hand the engine `GITLAB_TOKEN` (the wrapper +> strips it from the engine's environment), and never expose the model API key or `GITLAB_TOKEN` +> to pipelines from forks. + +## Cost and blast bounds + +The CI job carries `timeout: 15m`, `interruptible: true`, and `allow_failure: true`, so the +reviewer never blocks a merge and a new push supersedes the in-flight run. The wrapper adds diff +budgets on every surface (`AI_REVIEW_MAX_FILE_LINES`, `AI_REVIEW_MAX_DIFF_LINES` -- truncations +are listed in the summary), a turn cap where the engine supports one (`AI_REVIEW_MAX_TURNS`, on +the `claude` engine), and, in token modes, a no-op guard: a pipeline retry on an already-reviewed +head SHA exits before the engine runs. + +## Boundary with claude-code-ci-jobs + +The `claude-code-ci-jobs` skill runs Claude Code in CI as an **actor**: it edits files, commits, +and opens MRs from instructions. This skill runs an engine as a **reviewer**: read-only, structured +findings, deterministic posting. Neither replaces the other; a repository can carry both jobs. + +## Additional resources + +### Reference files + +- **`references/review-rubric.md`** -- the reviewer instruction, severity scale, findings contract +- **`references/review-modes.md`** -- mode and engine matrices, tokenless constraints, re-push semantics +- **`references/docker-agent-config.md`** -- config anatomy, toolsets, safety flags, provider swap +- **`references/gitlab-discussions-api.md`** -- position objects, diff_refs, the 400 fallback, marker dedup +- **`references/copilot-code-review.md`** -- how Copilot native review consumes instruction files + +### Examples + +- **`examples/mr-review-job.yml`** -- the CI job template +- **`examples/review-agent.yaml`** -- the docker-agent config template +- **`examples/copilot-code-review.instructions.md`** -- the Copilot instructions file +- **`examples/git-hook-pre-push.sh`** -- a pre-push hook wiring `ai-review.sh` diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/copilot-code-review.instructions.md b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/copilot-code-review.instructions.md new file mode 100644 index 0000000..fd0fac4 --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/copilot-code-review.instructions.md @@ -0,0 +1,33 @@ +--- +applyTo: "**" +--- + +# Code review instructions + +These instructions apply to automated code review of pull requests. + +Review only the changed lines and the minimum surrounding context needed to judge them. Look for, +in priority order: + +1. Correctness: logic errors, off-by-one, inverted conditions, unhandled null or undefined values, + broken error propagation, behavior that differs from what the name or documentation promises. +2. Security: injection (SQL, shell, path, template), secrets or tokens in code, missing + authorization checks, unsafe deserialization, SSRF, insecure defaults. +3. Concurrency and state: race conditions, unguarded shared state, missing idempotency where + retries occur, resource leaks. +4. Error handling: swallowed exceptions, empty catch blocks, errors logged but not surfaced, + fallbacks that hide failure. +5. API contracts: breaking changes to public interfaces, schema or serialization drift, + incompatible migrations. + +Do not comment on: + +- Style or formatting that a linter or formatter already enforces. +- Preferences with no concrete failure mode. +- Lines the pull request did not touch, unless the change breaks them. +- The same root cause more than once; comment at the most representative location. + +For each issue, state what breaks and under which conditions, and make the comment concrete enough +to act on without follow-up questions. Grade severity conservatively: reserve blocking language for +incorrect behavior, security holes, or data loss; phrase likely-but-conditional problems as +warnings; mark minor issues as nitpicks the author may reasonably ignore. diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/git-hook-pre-push.sh b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/git-hook-pre-push.sh new file mode 100644 index 0000000..0cae39a --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/git-hook-pre-push.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# pre-push -- AI-review the commits about to be pushed. +# +# Install: copy to .git/hooks/pre-push and mark executable, or point a hook +# manager (lefthook, husky, core.hooksPath) at it. Requires ai-review.sh from +# the same review bundle; adjust AI_REVIEW_SH if it lives elsewhere. +# +# Advisory by default: findings print, the push proceeds. Set +# AI_REVIEW_BLOCKING=1 to abort the push when a blocker is found. Either way, +# `git push --no-verify` skips the hook entirely. + +AI_REVIEW_SH="${AI_REVIEW_SH:-.gitlab/ai-review/ai-review.sh}" +ZERO=0000000000000000000000000000000000000000 + +if [ ! -f "$AI_REVIEW_SH" ]; then + echo "pre-push: $AI_REVIEW_SH not found; skipping AI review." >&2 + exit 0 +fi + +status=0 +while read -r _local_ref local_sha _remote_ref remote_sha; do + # Deleting a ref pushes nothing reviewable. + [ "$local_sha" = "$ZERO" ] && continue + + if [ "$remote_sha" = "$ZERO" ]; then + # New branch: review against the default branch when it exists. + base=$(git merge-base "origin/HEAD" "$local_sha" 2>/dev/null) || base="" + else + base="$remote_sha" + fi + [ -n "$base" ] || continue + + sh "$AI_REVIEW_SH" "$base...$local_sha" || status=1 +done + +# Advisory unless the user opted into gating: an engine failure or a finding +# must not strand a push by default. +[ "${AI_REVIEW_BLOCKING:-0}" = "1" ] || exit 0 +exit $status diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml new file mode 100644 index 0000000..05382d1 --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml @@ -0,0 +1,70 @@ +# Automated AI review of every merge request. +# +# Add this job to .gitlab-ci.yml (adapt `stage:` to the project) after copying +# the review bundle into .gitlab/ai-review/ -- the setup-mr-review command does +# both. The job runs an AI engine over the MR diff and delivers findings per +# AI_REVIEW_MODE; see review-modes.md in the mr-review-agent skill. +# +# CI/CD variables to create under Settings > CI/CD > Variables (all masked): +# - The model provider key the engine needs, e.g. ANTHROPIC_API_KEY. +# - GITLAB_TOKEN (optional): a project access token with `api` scope, +# Developer role or above. Without it the job cannot post MR comments +# (CI_JOB_TOKEN does not carry that permission) and the review lands in +# the job log and artifacts instead. +# Do not expose either variable to pipelines from forks: the reviewed code is +# untrusted input to the model. + +ai-mr-review: + stage: test + image: oven/bun:1 + rules: + # CI_MERGE_REQUEST_DRAFT needs GitLab 17.10+; the title regex covers + # older instances. Draft MRs are also skipped server-side by the wrapper. + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "true"' + when: never + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE =~ /^Draft:/' + when: never + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + variables: + # Tokenless (log) mode diffs locally against the MR base, which a shallow + # clone cannot reach. + GIT_DEPTH: "0" + AI_REVIEW_MODE: "inline" # inline | summary | log + AI_REVIEW_ENGINE: "docker-agent" # docker-agent | claude | codex | copilot + DOCKER_AGENT_VERSION: "v1.124.0" # pin; never latest + DOCKER_AGENT_SHA256: "" # optional: recorded checksum of the pinned binary; verified when set + TELEMETRY_ENABLED: "false" + before_script: + # oven/bun images carry no curl; download the pinned standalone binary + # with bun itself. No Docker daemon is involved at any point. + - | + if [ "$AI_REVIEW_ENGINE" = "docker-agent" ]; then + bun -e 'const v = process.env.DOCKER_AGENT_VERSION; + const url = `https://github.com/docker/docker-agent/releases/download/${v}/docker-agent-linux-amd64`; + const res = await fetch(url); + if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); + await Bun.write("/usr/local/bin/docker-agent", res);' + if [ -n "$DOCKER_AGENT_SHA256" ]; then + bun -e 'const { createHash } = await import("node:crypto"); + const data = new Uint8Array(await Bun.file("/usr/local/bin/docker-agent").arrayBuffer()); + const sum = createHash("sha256").update(data).digest("hex"); + if (sum !== process.env.DOCKER_AGENT_SHA256) throw new Error(`docker-agent sha256 mismatch: ${sum}`);' + fi + chmod +x /usr/local/bin/docker-agent + fi + - | + if [ "$AI_REVIEW_ENGINE" = "claude" ]; then + bun add -g @anthropic-ai/claude-code + fi + script: + - bun .gitlab/ai-review/post-mr-review.ts + timeout: 15m + # A broken or flaky reviewer must never block a merge. + allow_failure: true + # A new push supersedes the in-flight review instead of paying for both. + interruptible: true + artifacts: + when: always + expire_in: 1 week + paths: + - ai-review-artifacts/ diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/review-agent.yaml b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/review-agent.yaml new file mode 100644 index 0000000..8205fb6 --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/review-agent.yaml @@ -0,0 +1,46 @@ +# docker-agent configuration for automated merge-request review. +# +# Copied into a target repository (conventionally .gitlab/ai-review/) by the +# setup-mr-review command. The full review rubric and the diff arrive in the +# prompt built by post-mr-review.ts / ai-review.sh; this file sets the model, +# the persona, and -- deliberately -- a read-only toolset. +# +# SECURITY: the diff under review is untrusted input to the model. This agent +# runs unattended, so its capabilities are bounded here, not by approval +# prompts: filesystem reads and one fixed git command, no shell, no network, +# no MCP. Widening this toolset widens what a prompt-injected review can do. + +agents: + root: + # REQUIRED: replace with the model for the chosen provider, and set the + # provider's API key as a masked CI/CD variable. Examples: + # + # model: anthropic/claude-opus-5 # ANTHROPIC_API_KEY; recommended default + # model: anthropic/claude-sonnet-5 # ANTHROPIC_API_KEY; lower cost + # model: anthropic/claude-fable-5 # ANTHROPIC_API_KEY + # model: openai/gpt-5.6-sol # OPENAI_API_KEY + # + # Local models work through Docker Model Runner with a dmr/ model string. + # Provider list: https://docker.github.io/docker-agent/providers/overview/ + model: REPLACE_ME + + instruction: | + Act as a senior engineer reviewing a merge request. The prompt contains + the review rubric, the merge request context, and the diff. Follow the + rubric exactly: review only changed lines, report only concrete defects, + and use the blocker/warning/nit severity scale as defined. + + Read surrounding files only when needed to judge a changed line, and + never modify anything. + + Respond with only the findings-contract JSON object described in the + rubric. No prose before or after it. + + toolsets: + - type: filesystem + tools: ["read_file", "search_files_content"] + - type: script + shell: + recent_history: + description: The last 30 commits, for context on churn and intent + cmd: git log --oneline -n 30 diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md b/plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md new file mode 100644 index 0000000..118968d --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md @@ -0,0 +1,47 @@ +# GitHub Copilot native code review + +How the same review rubric reaches GitHub Copilot's built-in pull-request reviewer, which runs on +GitHub's side with no pipeline, engine, or script from this plugin. + +## Mechanism + +Copilot code review reads repository custom instructions from the PR's head branch: + +| File | Scope | +|---|---| +| `.github/copilot-instructions.md` | Repository-wide, all Copilot features | +| `.github/instructions/.instructions.md` | Path-specific via `applyTo` frontmatter; supported by Copilot code review and the coding agent | + +The shipped template `examples/copilot-code-review.instructions.md` is the second kind with +`applyTo: "**"`: the rubric's criteria, exclusions, and severity discipline rephrased for a +reviewer that posts its own native review comments. The findings JSON contract does not apply +here -- Copilot formats its own output -- so the instructions carry the judgment, not the schema. + +Frontmatter options: + +- `applyTo: "[, ...]"` -- which files the instructions cover. `"**"` covers the + repository; narrower globs (for example `app/models/**/*.rb`) scope review guidance to a + subtree, and several instruction files can coexist. +- `excludeAgent: "code-review"` restricts a file to the coding agent only; omit it so both use + the instructions. + +## Installation + +`/act-gitlab-ci:setup-mr-review copilot` copies the template to +`.github/instructions/code-review.instructions.md` in the target repository. Two conditions are +outside the file's control and worth stating to the user: + +1. Copilot code review must be enabled for the repository or organization, and the + "use custom instructions" toggle under Settings, Copilot, Code review must be on. +2. Instructions take effect for pull requests whose **head branch contains the file** -- reviews + of branches cut before the file merged do not see it. + +## Keeping the surfaces aligned + +The instructions file is a manual restatement of `review-rubric.md`, not a generated artifact. +When the rubric changes, update the instructions file in the same change; the divergence to watch +for is severity language, which the rubric defines precisely and prose restatements erode. + +GitLab has no equivalent instruction-file hook: its built-in reviewer surface is limited to +GitLab Duo, which is configured product-side. On GitLab, this plugin's CI job is the automated +reviewer. diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md b/plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md new file mode 100644 index 0000000..b274655 --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md @@ -0,0 +1,92 @@ +# docker-agent for merge-request review + +How the shipped `review-agent.yaml` uses docker-agent, and what is safe to change. +Upstream documentation: https://docker.github.io/docker-agent/ + +## What docker-agent is here + +A declarative agent runner: a YAML file names a model, an instruction, and toolsets; the binary +runs the loop. The review integration uses its headless mode only: + +```sh +docker-agent run --exec review-agent.yaml --json - < prompt.md +``` + +- `--exec` runs without the interactive TUI and exits when the conversation ends. +- `--json` emits newline-delimited JSON events (messages, tool calls, results). The wrapper's + parser reads the review out of this stream and saves it whole as `transcript.ndjson`. +- `-` takes the prompt on stdin, which avoids argv length limits on large diffs. + +The binary is standalone. `DOCKER_AGENT_VERSION` pins the GitHub release the CI job downloads +(asset `docker-agent-linux-amd64`); no Docker daemon, docker-in-docker, or `docker` CLI is +involved despite the name. + +## Config anatomy + +```yaml +agents: + root: + model: anthropic/claude-opus-5 # example; the shipped template says REPLACE_ME + instruction: | + ...persona and output discipline... + toolsets: + - type: filesystem + tools: ["read_file", "search_files_content"] + - type: script + shell: + recent_history: + description: The last 30 commits + cmd: git log --oneline -n 30 +``` + +The shipped template keeps the instruction short on purpose: the rubric and the diff arrive in the +prompt the wrapper builds, so the config only pins persona, output discipline, and capability. + +## Swapping providers + +Change `model:` and set the provider's key as a masked CI/CD variable. The engine reads standard +env var names (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, and so on -- see the +provider pages upstream). Local models via Docker Model Runner or compatible endpoints work the +same way with a `dmr/...` or custom provider model string; those need no key at all. + +## Toolsets: the security boundary + +The review agent runs unattended over untrusted diff content, so capability is bounded in the +config rather than by approval prompts: + +| Allowed | Why | +|---|---| +| `filesystem` with `read_file`, `search_files_content` | Judging a changed line often needs surrounding code | +| `script` with fixed, argument-free commands | Context (history) without an open shell | + +| Excluded | Why | +|---|---| +| `shell` | An open shell plus prompt injection equals arbitrary execution | +| `fetch` / remote `mcp` | Network egress is an exfiltration channel for anything in the environment | +| any write-capable tool | A reviewer that can edit is an actor, and belongs to `claude-code-ci-jobs` instead | + +Widening the toolset is a security decision, not a convenience: everything in the job's +environment (provider key included) is within reach of a prompt-injected agent with a shell. + +## Safety flags + +| Flag | Behavior | Use | +|---|---|---| +| `--safety restricted` | Denies tool calls outside configured permissions without prompting | The default (`AI_REVIEW_ENGINE_FLAGS`) | +| `--yolo` | Auto-approves everything | Fallback if a version denies the read-only tools under `restricted`; acceptable only because the toolset is already read-only | +| `--sandbox` | Runs the agent in a VM | Not usable inside typical CI containers (no nested virtualization) | + +## Structured output + +docker-agent has a structured-output feature (upstream: configuration/structured-output) that can +force the findings schema at the model layer. The shipped setup does not depend on it: the +instruction demands a JSON-only answer and the wrapper's parser tolerates events, fences, and +wrappers. On a docker-agent version whose structured output proves stable, adding it to +`review-agent.yaml` tightens the contract without changing anything downstream. + +## Version pinning + +`DOCKER_AGENT_VERSION` is pinned in `mr-review-job.yml` (`v1.124.0` as shipped). Headless flags +and event shapes are young and move between releases: bump deliberately, re-run the test suite, +and re-check `--safety` behavior when doing so. For stronger supply-chain footing, verify the +downloaded binary against a recorded checksum before `chmod +x` and fail the job on mismatch. diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md b/plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md new file mode 100644 index 0000000..d4b63fc --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md @@ -0,0 +1,85 @@ +# GitLab notes, discussions, and diff positions + +The API mechanics behind inline review delivery, and why they live in a deterministic script +rather than in the agent. + +## Notes vs discussions + +| Object | Endpoint | Used for | +|---|---|---| +| Note | `POST/PUT /projects/:id/merge_requests/:iid/notes[/:note_id]` | The sticky summary, and the fallback for unmappable findings | +| Discussion | `POST /projects/:id/merge_requests/:iid/discussions` | One thread per finding, anchored to a diff line, resolvable | + +A discussion with a `position` renders on the changed line in the diff view -- the GitHub +"inline review comment" equivalent. A plain note renders in the MR activity stream. + +## Position objects + +```json +{ + "body": "**[Blocker]** ...", + "position": { + "position_type": "text", + "base_sha": "", + "head_sha": "", + "start_sha": "", + "new_path": "src/payments/charge.ts", + "old_path": "src/payments/charge.ts", + "new_line": 42 + } +} +``` + +Rules the wrapper (`positionFor` in `post-mr-review.ts`) encodes: + +- All three SHAs come from the MR's `diff_refs`, returned by + `GET /projects/:id/merge_requests/:iid/changes`. Constructing them from other CI variables + drifts on rebases and merged-result pipelines; use `diff_refs`. +- An **added or modified** line: `new_line` only. +- A **deleted** line: `old_line` only. +- `old_path` is required even when the file was not renamed. + +## The 400 fallback is a designed path + +GitLab validates the position against the actual diff and answers `400 Bad Request` when it +cannot map it -- typically a context line the MR did not change, a line index past a hunk, or a +rename edge case. Models produce such positions at a steady rate, so the wrapper treats 400 as +expected: the finding is delivered as a plain note prefixed with `path:line` instead of being +dropped. Any other non-2xx status is a real error and fails the run. + +This is the core reason posting is deterministic code and not an agent tool call: the position +contract is exacting, silent partial delivery is unacceptable, and a fixture can pin the fallback +behavior in tests (`scripts/tests/mr-review/`). + +## Marker-based stickiness + +Every posted body ends with ``, +invisible in rendered markdown. `kind` is `summary` on the sticky note and `finding` on +discussions and fallback notes -- the distinction is load-bearing, because GitLab lists notes +newest-first and a fallback note would otherwise be mistaken for the summary and overwritten. +The wrapper identifies its own past output purely by this marker: + +| Check | Action | +|---|---| +| Note with a `kind=summary` marker exists | `PUT` the summary onto it instead of posting a new one | +| That marker's SHA equals the current head | The head is already reviewed; exit without running the engine | +| Discussion with a marker, older SHA, unresolved | `PUT resolved=true`, then post fresh findings | +| No marker (a human's note or thread) | Never touched | + +Fallback notes are plain notes, so unlike discussions they cannot be resolved on re-push; stale +ones stay in the activity stream, identifiable by the older SHA in their marker. + +Identifying by marker rather than by author makes the behavior independent of which token or bot +user posted the earlier review. + +## Token requirements + +Creating notes and discussions on an MR requires a token with `api` scope acting as a member with +at least the Developer role -- a project access token stored masked as `GITLAB_TOKEN`. +`CI_JOB_TOKEN`'s permission set does not include these endpoints on self-managed or gitlab.com +instances, which is why tokenless pipelines run `log` mode. The wrapper authenticates with the +`PRIVATE-TOKEN` header. + +Pagination: the wrapper reads the first 100 notes and discussions. An MR with more bot-relevant +history than that is degenerate; the marker search only needs the summary note, which stays +findable because it is updated, not re-posted. diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md b/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md new file mode 100644 index 0000000..ece5478 --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md @@ -0,0 +1,83 @@ +# Delivery modes, engines, and re-push semantics + +## Mode matrix + +`AI_REVIEW_MODE` on the CI job selects delivery. The wrapper resolves the effective mode at +runtime: any comment mode without `GITLAB_TOKEN` downgrades to `log` with a notice on stderr, +because `CI_JOB_TOKEN` cannot create MR notes or discussions. + +| Mode | Token | What the author sees | +|---|---|---| +| `inline` | `GITLAB_TOKEN` | A discussion anchored to each finding's diff line, plus one summary note pinned to the MR. The closest GitLab equivalent of a GitHub Copilot or claude-code-action review. | +| `summary` | `GITLAB_TOKEN` | The summary note only: counts, a findings table with `path:line` locations, truncation notices. | +| `log` | none | The rendered report in the job log, plus artifacts. | + +Artifacts are written in every mode, under `ai-review-artifacts/`: + +| File | Content | +|---|---| +| `transcript.ndjson` | The engine's raw output, for debugging a bad review | +| `findings.json` | The validated findings-contract object | +| `review.md` | The rendered summary, as it would appear on the MR | + +## Diff acquisition + +| Mode | Source | Constraint | +|---|---|---| +| `inline`, `summary` | `GET /projects/:id/merge_requests/:iid/changes` | Also supplies `diff_refs` for positioning and the server-side draft flag | +| `log` | `git diff $CI_MERGE_REQUEST_DIFF_BASE_SHA...HEAD` | The base SHA must be reachable: set `GIT_DEPTH: "0"` on the job (the template does) or fetch it explicitly | + +Diff budgets apply in both paths: `AI_REVIEW_MAX_FILE_LINES` (default 1500) per file and +`AI_REVIEW_MAX_DIFF_LINES` (default 6000) total. Files cut by either budget are named in the +summary so a partial review never masquerades as a full one. + +## Engine matrix + +| `AI_REVIEW_ENGINE` | Invocation | Auth | Notes | +|---|---|---|---| +| `docker-agent` | `docker-agent run --exec review-agent.yaml --json --safety restricted -` | Provider key per `model:` in the config | Default. Provider-agnostic. Standalone binary, pinned by `DOCKER_AGENT_VERSION`; no Docker daemon. | +| `claude` | `claude -p --output-format json --max-turns N --allowedTools "Read Grep Glob"` | `ANTHROPIC_API_KEY`, subscription token, or the Bedrock/Vertex setups in `ci-auth-providers` | The Docker-free pipeline path. | +| `codex` | `codex exec --json` | OpenAI credentials | Best effort: flags move between versions; check `codex exec --help`. | +| `copilot` | `copilot -p ` | GitHub Copilot auth | Best effort: same caveat. The prompt travels as one argv element, so very large diffs can exceed the OS argument limit -- lower `AI_REVIEW_MAX_DIFF_LINES` or switch to `AI_REVIEW_ENGINE_CMD` with a stdin-reading invocation. | +| any | `AI_REVIEW_ENGINE_CMD` | caller's concern | Full command via `sh -c` on both surfaces (CI wrapper and local harness); prompt on stdin; must print the findings JSON. | + +The local harness auto-detects an engine when `AI_REVIEW_ENGINE` is unset: the first of +`docker-agent`, `claude`, `codex`, `copilot` found on PATH. + +Safety flags for docker-agent default to `--safety restricted` (`AI_REVIEW_ENGINE_FLAGS` +overrides). If a docker-agent version denies the read-only tools under `restricted`, `--yolo` is +an acceptable fallback **only because** the config's toolsets are already read-only: the approval +flag governs prompting, the toolset governs capability. + +The engine subprocess never receives `GITLAB_TOKEN`, `GITLAB_ACCESS_TOKEN`, or `CI_JOB_TOKEN` -- +the wrapper and the harness both strip them, so a prompt-injected engine has no way to post. +The rest of the job environment necessarily remains reachable, the model provider key included: +that is what makes the read-only toolset and the no-network rule load-bearing rather than +decorative. + +## Sticky and re-push semantics + +Every body the wrapper posts embeds `` +(`kind=summary` on the sticky note, `kind=finding` on discussions and fallback notes). + +- **Summary note**: found by marker and updated in place (`PUT`); created once, then stable, so + the MR never accumulates a stack of summaries. +- **Pipeline retry** (same head SHA as the marker): the run exits before invoking the engine. + Retries are free. +- **New push** (different head SHA): stale bot discussions -- marker present, SHA differs, still + unresolved -- are resolved, fresh discussions are posted against the new diff, and the summary + updates. Resolved threads stay visible but collapsed, preserving the audit trail. Human threads + are never touched: no marker, no action. +- **Draft MRs**: skipped by the job rules (`CI_MERGE_REQUEST_DRAFT` on GitLab 17.10+, a + `Draft:` title regex elsewhere) and, belt-and-braces, by the wrapper via the API's draft flag + in token modes. In `log` mode the wrapper never calls the API, so the job rules are the only + draft guard. + +## Failure behavior + +| Failure | Result | +|---|---| +| Engine output has no valid findings object | Exit 1; transcript saved to artifacts; `allow_failure: true` keeps the MR mergeable | +| A finding's position is rejected (HTTP 400) | That finding posts as a plain note prefixed `path:line`; the rest post normally. Expected for context lines and renames, not an error. | +| Other GitLab API errors | Exit 1 with the status code; nothing is silently dropped | +| Empty diff | Exit 0, "nothing to review" | diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-rubric.md b/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-rubric.md new file mode 100644 index 0000000..69e9209 --- /dev/null +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-rubric.md @@ -0,0 +1,83 @@ +# Review rubric and findings contract + +The single source of truth for what an automated reviewer looks for and how it reports it. Every +surface derives from this file: the docker-agent config embeds the instruction, the CI wrapper and +shell harness send it to CLI engines, the `review-mr` command applies it in-session, and the GitHub +Copilot instructions file restates it in Copilot's format. A change here is a change to all of them. + +## Reviewer instruction + +Act as a senior engineer reviewing a merge request. Review **only the changed lines** in the +provided diff and the minimum surrounding context needed to judge them. + +Look for, in priority order: + +1. **Correctness** -- logic errors, off-by-one, inverted conditions, unhandled null/undefined, + broken error propagation, results that differ from what the name or docs promise. +2. **Security** -- injection (SQL, shell, path, template), secrets or tokens in code, missing + authorization checks, unsafe deserialization, SSRF, insecure defaults. +3. **Concurrency and state** -- race conditions, unguarded shared state, missing idempotency where + retries occur, resource leaks. +4. **Error handling** -- swallowed exceptions, empty catch blocks, errors logged but not surfaced, + fallbacks that hide failure. +5. **API contracts** -- breaking changes to public interfaces, schema or serialization drift, + incompatible migrations. + +Do **not** report: + +- Style and formatting a linter or formatter already enforces. +- Preferences with no concrete failure mode ("consider renaming", "could be a helper"). +- Issues in lines the merge request did not touch, unless the change breaks them. +- Duplicates: one finding per root cause, at the most representative location. + +Every finding must cite the file path and line number **in the new version of the file** (use the +old version's line only for pure deletions), state what breaks and when, and be concrete enough +that the author can act without asking follow-up questions. + +## Severity scale + +| Severity | Meaning | Example | +|---|---|---| +| `blocker` | Merging this causes incorrect behavior, a security hole, or data loss | SQL built by string concatenation from request input | +| `warning` | Defensible today, likely to fail under plausible conditions | Retry loop with no backoff or bound | +| `nit` | Real but minor; author may reasonably ignore it | Misleading variable name that survives review | + +When unsure between two severities, choose the lower one. A reviewer that cries blocker is muted +within a week. + +## Findings contract + +Scripted engines must emit exactly one JSON object matching this shape, and nothing else that could +be mistaken for it: + +```json +{ + "summary": "One paragraph: overall assessment, notable risks, anything skipped.", + "findings": [ + { + "path": "src/billing/invoice.ts", + "new_line": 142, + "old_line": null, + "severity": "blocker", + "title": "Refund amount is never validated against the invoice total", + "body": "`refund()` accepts `amount` from the request body and passes it to the ledger unchecked. A negative amount credits the customer twice. Validate `0 < amount <= invoice.total` before posting." + } + ] +} +``` + +Field rules: + +| Field | Type | Rule | +|---|---|---| +| `summary` | string | Required. Plain prose, no markdown headings. Mention any files skipped for size. | +| `findings` | array | Required. Empty array means a clean review, not a failed one. | +| `path` | string | Required. Repository-relative, as it appears in the diff's new version. | +| `new_line` | number or null | Line in the new file. Required unless the finding is on a deleted line. | +| `old_line` | number or null | Line in the old file. Only for deletions; null otherwise. | +| `severity` | string | `blocker`, `warning`, or `nit`. Nothing else. | +| `title` | string | One line, under 80 characters, states the defect, not the fix. | +| `body` | string | The claim, the failure scenario, and a concrete suggestion. Markdown allowed. | + +Interactive surfaces (an in-session review, Copilot's native reviewer) present the same content as +prose or native review comments; the JSON contract binds only engines whose output a script parses. diff --git a/plugins/act-gitlab-ci/skills/review-mr/SKILL.md b/plugins/act-gitlab-ci/skills/review-mr/SKILL.md new file mode 100644 index 0000000..0164eec --- /dev/null +++ b/plugins/act-gitlab-ci/skills/review-mr/SKILL.md @@ -0,0 +1,12 @@ +--- +name: review-mr +description: "Review a GitLab merge request against the shared rubric in the current session, and post the findings only on explicit confirmation." +--- + +# Review MR + +Read and follow the canonical procedure in [../../commands/review-mr.md](../../commands/review-mr.md). + +Treat the user's current request as the procedure input. Ignore the source file's YAML frontmatter +and any Claude-only invocation syntax. Use equivalent tools available on the current host, preserve +all safety checks, and resolve bundled resources from this plugin rather than the user's project. diff --git a/plugins/act-gitlab-ci/skills/setup-mr-review/SKILL.md b/plugins/act-gitlab-ci/skills/setup-mr-review/SKILL.md new file mode 100644 index 0000000..22fb54a --- /dev/null +++ b/plugins/act-gitlab-ci/skills/setup-mr-review/SKILL.md @@ -0,0 +1,12 @@ +--- +name: setup-mr-review +description: "Install automated AI code review as a GitLab CI merge-request job, a git pre-push hook, or GitHub Copilot review instructions." +--- + +# Setup MR Review + +Read and follow the canonical procedure in [../../commands/setup-mr-review.md](../../commands/setup-mr-review.md). + +Treat the user's current request as the procedure input. Ignore the source file's YAML frontmatter +and any Claude-only invocation syntax. Use equivalent tools available on the current host, preserve +all safety checks, and resolve bundled resources from this plugin rather than the user's project. From 9224276faa94ee272d6e0dcd8a3ed6d726906273 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:20:20 -0500 Subject: [PATCH 2/5] Add devops tag and improve engine robustness --- .claude-plugin/marketplace.json | 3 +- .../0005-mr-review-engine-agnostic.md | 7 +- .../act-gitlab-ci/.claude-plugin/plugin.json | 3 +- plugins/act-gitlab-ci/commands/review-mr.md | 5 +- plugins/act-gitlab-ci/scripts/ai-review.sh | 18 +++- .../act-gitlab-ci/scripts/post-mr-review.ts | 61 +++++++++--- .../mr-review/fixtures/mr-changes-draft.json | 19 ++++ .../mr-review/fixtures/stub-env-probe.sh | 18 ++++ .../scripts/tests/mr-review/run-tests.sh | 52 ++++++++++- .../scripts/tests/mr-review/unit.test.ts | 92 +++++++++++++++++++ .../examples/mr-review-job.yml | 3 +- 11 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes-draft.json create mode 100644 plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-env-probe.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc26f31..f1af39c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -146,7 +146,8 @@ "vertex", "code-review", "merge-requests", - "docker-agent" + "docker-agent", + "devops" ], "relevance": { "topic": "GitLab CI/CD pipelines, merge request review, and GitLab tooling", diff --git a/docs/decisions/0005-mr-review-engine-agnostic.md b/docs/decisions/0005-mr-review-engine-agnostic.md index 4a7475e..9c83cc1 100644 --- a/docs/decisions/0005-mr-review-engine-agnostic.md +++ b/docs/decisions/0005-mr-review-engine-agnostic.md @@ -78,9 +78,10 @@ would move the rubric and its restatements together. `mr-review-agent` skill states when each applies. Without a project access token the capability degrades to `log` mode rather than failing. -**Two engines are tested, two are best-effort.** docker-agent and claude invocations are exercised -by the fixture suite; codex and copilot CLI flags are young and verified only at setup time, with -`AI_REVIEW_ENGINE_CMD` as the escape hatch. The suite (`scripts/tests/mr-review/`) runs with no +**Two engines are tested, two are best-effort.** The fixture suite exercises docker-agent's and +claude's output envelopes (saved transcripts; no engine is ever spawned in tests); codex and +copilot CLI flags are young and verified only at setup time, with `AI_REVIEW_ENGINE_CMD` as the +escape hatch. The suite (`scripts/tests/mr-review/`) runs with no network and no engines, so the repository gate pins parsing, positioning, the 400 fallback, mode downgrades, marker stickiness, and re-push resolution — not model quality. diff --git a/plugins/act-gitlab-ci/.claude-plugin/plugin.json b/plugins/act-gitlab-ci/.claude-plugin/plugin.json index 3ef9ff1..cf27ad9 100644 --- a/plugins/act-gitlab-ci/.claude-plugin/plugin.json +++ b/plugins/act-gitlab-ci/.claude-plugin/plugin.json @@ -22,6 +22,7 @@ "vertex", "code-review", "merge-requests", - "docker-agent" + "docker-agent", + "devops" ] } diff --git a/plugins/act-gitlab-ci/commands/review-mr.md b/plugins/act-gitlab-ci/commands/review-mr.md index d918667..8e2dc51 100644 --- a/plugins/act-gitlab-ci/commands/review-mr.md +++ b/plugins/act-gitlab-ci/commands/review-mr.md @@ -49,7 +49,10 @@ After reporting, offer to post the review to the MR -- and only proceed on an ex - With the glab CLI or a `GITLAB_TOKEN` available, post one summary note. Append the marker line `` so the CI job's sticky-note - logic recognizes and updates it instead of duplicating it. Inline positioned discussions from an interactive + logic recognizes and updates it instead of duplicating it. Say plainly that this marker also + makes the CI review job treat the current head as already reviewed and skip its own run + (including inline discussions) until the next push -- posting an in-session review supersedes + the automated one for that revision. Inline positioned discussions from an interactive session are rarely worth the fragility; the CI job owns that surface. - With no credentials, say so and leave the review in the conversation. diff --git a/plugins/act-gitlab-ci/scripts/ai-review.sh b/plugins/act-gitlab-ci/scripts/ai-review.sh index 420af85..d033a2e 100644 --- a/plugins/act-gitlab-ci/scripts/ai-review.sh +++ b/plugins/act-gitlab-ci/scripts/ai-review.sh @@ -90,6 +90,10 @@ PROMPT_FILE="$WORKDIR/prompt.md" OUT_FILE="$WORKDIR/engine-output.txt" MAX_TURNS="${AI_REVIEW_MAX_TURNS:-25}" +# Set by run_engine on OUR configuration errors, so an engine that happens to +# exit 2 is not mistaken for one. +CONFIG_ERR=0 + run_engine() { if [ -n "${AI_REVIEW_ENGINE_CMD:-}" ]; then GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= sh -c "$AI_REVIEW_ENGINE_CMD" <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" @@ -104,6 +108,7 @@ run_engine() { fi if [ -z "$engine" ]; then err "no engine found (docker-agent, claude, codex or copilot); set AI_REVIEW_ENGINE_CMD" + CONFIG_ERR=1 return 2 fi @@ -129,12 +134,19 @@ run_engine() { ;; copilot) # Best effort: verify flags against `copilot --help` for the installed - # version; override with AI_REVIEW_ENGINE_CMD on drift. + # version; override with AI_REVIEW_ENGINE_CMD on drift. The prompt rides + # argv (no stdin mode), which the OS caps at 128 KiB per element. + if [ "$(wc -c <"$PROMPT_FILE")" -gt 120000 ]; then + err "prompt too large for the copilot engine's argument passing; lower the diff size or use AI_REVIEW_ENGINE_CMD with a stdin-reading command" + CONFIG_ERR=1 + return 2 + fi GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ - copilot -p "$(cat "$PROMPT_FILE")" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + copilot -p "$(cat "$PROMPT_FILE")" "$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" ;; *) err "unknown AI_REVIEW_ENGINE \"$engine\"" + CONFIG_ERR=1 return 2 ;; esac @@ -142,7 +154,7 @@ run_engine() { run_engine engine_status=$? -if [ "$engine_status" -eq 2 ]; then +if [ "$CONFIG_ERR" -eq 1 ]; then exit 2 fi if [ ! -s "$OUT_FILE" ]; then diff --git a/plugins/act-gitlab-ci/scripts/post-mr-review.ts b/plugins/act-gitlab-ci/scripts/post-mr-review.ts index 8519e32..b94c662 100644 --- a/plugins/act-gitlab-ci/scripts/post-mr-review.ts +++ b/plugins/act-gitlab-ci/scripts/post-mr-review.ts @@ -537,19 +537,39 @@ function readJsonFile(path: string): unknown { return JSON.parse(readFileSync(path, "utf8")); } -function runEngine(spec: EngineSpec, prompt: string, env: Record): string { - // The engine must not see any GitLab token: it processes untrusted MR - // content, and posting is this script's job. CI_JOB_TOKEN goes too -- no - // engine needs it. The rest of the job environment (provider key included) - // necessarily remains reachable. +/** + * The environment an engine subprocess may see. No GitLab token of any kind: + * the engine processes untrusted MR content, and posting is this script's + * job. The rest of the job environment (provider key included) necessarily + * remains reachable. + */ +export function engineChildEnv(env: Record): Record { const STRIPPED = ["GITLAB_TOKEN", "GITLAB_ACCESS_TOKEN", "CI_JOB_TOKEN"]; const childEnv: Record = {}; for (const [k, v] of Object.entries(env)) { if (v !== undefined && !STRIPPED.includes(k)) childEnv[k] = v; } childEnv.TELEMETRY_ENABLED = childEnv.TELEMETRY_ENABLED || "false"; + return childEnv; +} + +// Linux caps a single argv element at 128 KiB (MAX_ARG_STRLEN). Prompts ride +// argv only for engines with no stdin mode; refuse before the kernel does. +const MAX_ARG_PROMPT_BYTES = 120_000; + +function runEngine(spec: EngineSpec, prompt: string, env: Record): string { + const childEnv = engineChildEnv(env); const argv = [...spec.argv]; - if (spec.promptVia === "arg") argv.push(prompt); + if (spec.promptVia === "arg") { + if (Buffer.byteLength(prompt, "utf8") > MAX_ARG_PROMPT_BYTES) { + fail( + `the prompt (${Buffer.byteLength(prompt, "utf8")} bytes) exceeds the OS argument limit for this ` + + `engine; lower AI_REVIEW_MAX_DIFF_LINES or use AI_REVIEW_ENGINE_CMD with a stdin-reading command`, + 2, + ); + } + argv.push(prompt); + } const result = spawnSync(argv[0], argv.slice(1), { input: spec.promptVia === "stdin" ? prompt : undefined, env: childEnv, @@ -573,13 +593,25 @@ function localDiff(baseSha: string): FileDiff[] { if (result.status !== 0) { fail(`git diff against ${baseSha} failed (shallow clone? set GIT_DEPTH: "0"): ${result.stderr}`, 2); } - // Split the unified diff back into per-file chunks so truncation budgets - // apply per file, same as the API path. + return parseGitDiff(result.stdout || ""); +} + +/** + * Split a raw `git diff` back into per-file chunks so truncation budgets + * apply per file, same as the API path. The body starts at the first hunk: + * truncateDiff() adds its own header lines, so keeping git's index/---/+++ + * metadata would duplicate them in the prompt. Paths that git quotes + * (spaces, non-ASCII) do not match and are skipped -- a documented + * limitation of tokenless mode. + */ +export function parseGitDiff(raw: string): FileDiff[] { const files: FileDiff[] = []; - for (const chunk of (result.stdout || "").split(/^diff --git /m).slice(1)) { + for (const chunk of raw.split(/^diff --git /m).slice(1)) { const m = chunk.match(/^a\/(\S+) b\/(\S+)/); if (!m) continue; - const body = chunk.split("\n").slice(1).join("\n"); + const lines = chunk.split("\n").slice(1); + const hunkStart = lines.findIndex((l) => l.startsWith("@@")); + const body = (hunkStart === -1 ? lines : lines.slice(hunkStart)).join("\n"); files.push({ old_path: m[1], new_path: m[2], diff: body }); } return files; @@ -696,7 +728,14 @@ async function main(): Promise { rawOutput = readFileSync(env.AI_REVIEW_FIXTURE_OUTPUT, "utf8"); } else { const engine = env.AI_REVIEW_ENGINE || "docker-agent"; - rawOutput = runEngine(engineSpec(engine, env), prompt, env); + let spec: EngineSpec; + try { + spec = engineSpec(engine, env); + } catch (err) { + // A typo'd engine is a configuration error, same as a typo'd mode. + fail(err instanceof Error ? err.message : String(err), 2); + } + rawOutput = runEngine(spec, prompt, env); } writeFileSync(join(artifactsDir, "transcript.ndjson"), rawOutput); diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes-draft.json b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes-draft.json new file mode 100644 index 0000000..4d65c65 --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes-draft.json @@ -0,0 +1,19 @@ +{ + "iid": 8, + "title": "Draft: rework the charge path", + "description": "Not ready for review.", + "draft": true, + "work_in_progress": true, + "diff_refs": { + "base_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "head_sha": "beefbeefbeefbeefbeefbeefbeefbeefbeefbeef", + "start_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" + }, + "changes": [ + { + "old_path": "src/payments/charge.ts", + "new_path": "src/payments/charge.ts", + "diff": "@@ -1 +1 @@\n+wip\n" + } + ] +} diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-env-probe.sh b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-env-probe.sh new file mode 100644 index 0000000..2b7304d --- /dev/null +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-env-probe.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# A fake review engine that reports its own environment: whether any GitLab +# token is visible (they must all be stripped or blank) and whether an +# unrelated control variable survived (it must). The summary encodes both, so +# the harness test asserts on the rendered report. +cat >/dev/null + +leak="clean" +if [ -n "${GITLAB_TOKEN:-}" ] || [ -n "${GITLAB_ACCESS_TOKEN:-}" ] || [ -n "${CI_JOB_TOKEN:-}" ]; then + leak="token-leaked" +fi + +control="control-missing" +if [ "${PROBE_CONTROL:-}" = "present" ]; then + control="control-ok" +fi + +printf '{"summary":"env probe: %s %s","findings":[]}\n' "$leak" "$control" diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh b/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh index 661c1b6..0b6834a 100644 --- a/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh @@ -58,7 +58,7 @@ fi # --- --extract contract -------------------------------------------------------- out=$(bun "$WRAPPER" --extract "$FIXTURES/transcript.ndjson" 2>&1) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q '"findings"'; then +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Charge amount accepted without currency validation'; then pass "--extract parses a docker-agent transcript" else fail "--extract parses a docker-agent transcript" "$out" @@ -83,10 +83,11 @@ else fail "--blocking exits 0 with no blocker" fi -if bun "$WRAPPER" --extract "$FIXTURES/transcript-malformed.ndjson" >/dev/null 2>&1; then - fail "malformed transcript exits non-zero" +out=$(bun "$WRAPPER" --extract "$FIXTURES/transcript-malformed.ndjson" 2>&1) +if [ $? -ne 0 ] && printf '%s' "$out" | grep -q 'no valid findings-contract object'; then + pass "malformed transcript exits non-zero with the contract error" else - pass "malformed transcript exits non-zero" + fail "malformed transcript exits non-zero with the contract error" "$out" fi # --- dry-run: inline mode over fixtures ---------------------------------------- @@ -150,6 +151,33 @@ else fail "dry-run writes findings.json and review.md artifacts" fi +# --- dry-run: draft MRs are skipped server-side --------------------------------- + +out=$(AI_REVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token AI_REVIEW_MODE=inline \ + CI_API_V4_URL="https://gitlab.example.com/api/v4" CI_PROJECT_ID="123" CI_MERGE_REQUEST_IID="8" \ + CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="$HEAD_SHA" \ + AI_REVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes-draft.json" \ + AI_REVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ + AI_REVIEW_ARTIFACTS="$WORK/artifacts-draft" \ + bun "$WRAPPER" 2>&1) +if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'draft merge request' && + ! printf '%s' "$out" | grep -q '"planned"'; then + pass "draft MR skips the review and plans no actions" +else + fail "draft MR skips the review and plans no actions" "$out" +fi + +# --- configuration typos exit 2, not 1 ------------------------------------------ + +AI_REVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token AI_REVIEW_MODE=inlien \ + CI_MERGE_REQUEST_IID="7" bun "$WRAPPER" >/dev/null 2>&1 +status=$? +if [ "$status" -eq 2 ]; then + pass "unknown AI_REVIEW_MODE exits 2" +else + fail "unknown AI_REVIEW_MODE exits 2" "exit $status" +fi + # --- dry-run: pipeline retry is a no-op ---------------------------------------- out=$(run_dry "abc123abc123" inline fake-token) @@ -170,6 +198,22 @@ else fail "missing token downgrades inline to log with a notice" "$out $(cat "$WORK/dry-stderr.txt")" fi +# --- harness: engines never see GitLab tokens ------------------------------------ + +printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n+probe\n' >"$WORK/probe.diff" + +out=$(TMPDIR="$WORK" \ + GITLAB_TOKEN=super-secret GITLAB_ACCESS_TOKEN=also-secret CI_JOB_TOKEN=job-secret \ + PROBE_CONTROL=present \ + AI_REVIEW_DIFF_FILE="$WORK/probe.diff" \ + AI_REVIEW_ENGINE_CMD="sh $FIXTURES/stub-env-probe.sh" \ + sh "$HARNESS" 2>&1) +if printf '%s' "$out" | grep -q 'env probe: clean control-ok'; then + pass "harness strips GitLab tokens but not the rest of the env" +else + fail "harness strips GitLab tokens but not the rest of the env" "$out" +fi + # --- harness: stub engine ------------------------------------------------------- printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n+changed\n' >"$WORK/stub.diff" diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts b/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts index 0d788fa..ccb8686 100644 --- a/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts +++ b/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts @@ -11,10 +11,14 @@ import { type Action, type DiffRefs, type Finding, + buildPrompt, + engineChildEnv, + engineSpec, executeActions, extractReview, markerKindOf, markerShaOf, + parseGitDiff, planActions, positionFor, renderSummary, @@ -105,6 +109,94 @@ describe("resolveMode", () => { }); }); +describe("engineSpec", () => { + test("each named engine produces its documented argv shape", () => { + expect(engineSpec("docker-agent", {}).argv).toEqual([ + "docker-agent", "run", "--exec", ".gitlab/ai-review/review-agent.yaml", "--json", "--safety", "restricted", "-", + ]); + expect(engineSpec("claude", {}).argv).toEqual([ + "claude", "-p", "--output-format", "json", "--max-turns", "25", "--allowedTools", "Read Grep Glob", + ]); + expect(engineSpec("codex", {}).argv).toEqual(["codex", "exec", "--json"]); + expect(engineSpec("copilot", {})).toEqual({ argv: ["copilot", "-p"], promptVia: "arg" }); + }); + + test("AI_REVIEW_ENGINE_CMD runs through a shell, matching ai-review.sh", () => { + const spec = engineSpec("docker-agent", { AI_REVIEW_ENGINE_CMD: "my-engine --flag 'quoted arg'" }); + expect(spec.argv).toEqual(["sh", "-c", "my-engine --flag 'quoted arg'"]); + expect(spec.promptVia).toBe("stdin"); + }); + + test("a whitespace-only override falls back to the named engine", () => { + expect(engineSpec("codex", { AI_REVIEW_ENGINE_CMD: " " }).argv[0]).toBe("codex"); + }); + + test("an unknown engine throws instead of guessing", () => { + expect(() => engineSpec("gpt", {})).toThrow("unknown AI_REVIEW_ENGINE"); + }); +}); + +describe("engineChildEnv", () => { + test("strips every GitLab token and keeps the rest", () => { + const child = engineChildEnv({ + GITLAB_TOKEN: "secret", + GITLAB_ACCESS_TOKEN: "secret2", + CI_JOB_TOKEN: "secret3", + ANTHROPIC_API_KEY: "provider-key", + CI_PROJECT_ID: "123", + }); + expect(child.GITLAB_TOKEN).toBeUndefined(); + expect(child.GITLAB_ACCESS_TOKEN).toBeUndefined(); + expect(child.CI_JOB_TOKEN).toBeUndefined(); + // Positive controls: stripping must not mean "empty env". + expect(child.ANTHROPIC_API_KEY).toBe("provider-key"); + expect(child.CI_PROJECT_ID).toBe("123"); + }); + + test("telemetry defaults off and an explicit value wins", () => { + expect(engineChildEnv({}).TELEMETRY_ENABLED).toBe("false"); + expect(engineChildEnv({ TELEMETRY_ENABLED: "true" }).TELEMETRY_ENABLED).toBe("true"); + }); +}); + +describe("parseGitDiff", () => { + const raw = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 1111111..2222222 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,2 +1,2 @@", + "-old", + "+new", + 'diff --git "a/with space.ts" "b/with space.ts"', + "@@ -1 +1 @@", + "+x", + ].join("\n"); + + test("bodies start at the first hunk so truncateDiff headers are not duplicated", () => { + const files = parseGitDiff(raw); + expect(files).toHaveLength(1); + expect(files[0].new_path).toBe("src/app.ts"); + expect(files[0].diff.startsWith("@@")).toBe(true); + expect(files[0].diff).not.toContain("+++ b/"); + }); + + test("quoted paths are skipped, not mangled", () => { + expect(parseGitDiff(raw).some((f) => f.new_path.includes("space"))).toBe(false); + }); +}); + +describe("buildPrompt", () => { + test("names truncated files so a partial review cannot pose as a full one", () => { + const prompt = buildPrompt("RUBRIC", { title: "t", description: "d" }, "+x", ["big.ts"]); + expect(prompt).toContain("truncated or omitted for size"); + expect(prompt).toContain("big.ts"); + }); + test("carries no truncation note when nothing was cut", () => { + expect(buildPrompt("RUBRIC", { title: "t", description: "d" }, "+x", [])).not.toContain("truncated or omitted"); + }); +}); + describe("truncateDiff", () => { const file = (path: string, lines: number) => ({ old_path: path, diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml index 05382d1..57edb3f 100644 --- a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml +++ b/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml @@ -54,7 +54,8 @@ ai-mr-review: fi - | if [ "$AI_REVIEW_ENGINE" = "claude" ]; then - bun add -g @anthropic-ai/claude-code + # Pin CLAUDE_CODE_VERSION to a known-good release for reproducible runs. + bun add -g "@anthropic-ai/claude-code${CLAUDE_CODE_VERSION:+@$CLAUDE_CODE_VERSION}" fi script: - bun .gitlab/ai-review/post-mr-review.ts From 60ca43e0d0e725ff5ee388e04fa9ad39e8f49b2b Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:28:52 -0500 Subject: [PATCH 3/5] feat(code-reviews): add engine-agnostic automated code review plugin One rubric and findings contract delivered on four surfaces: a GitLab CI job reviewing every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) with inline discussions, a sticky summary and a tokenless log fallback; a git pre-push hook with an engine-dispatching harness; an in-session review command; and GitHub Copilot native review instructions. Posting is deterministic script code pinned by a fixture-driven test suite; engines never receive GitLab tokens. Removes the empty plugins/code-review scaffold, records the design in ADR 0005, and trims act-gitlab-ci back to pipeline tooling (0.2.1, model identifier refresh only). --- .agents/plugins/marketplace.json | 3 +- .claude-plugin/marketplace.json | 47 +++++- .github/plugin/marketplace.json | 3 +- CLAUDE.md | 2 +- README.md | 4 +- .../0005-mr-review-engine-agnostic.md | 52 +++--- docs/decisions/README.md | 2 +- .../act-gitlab-ci/.claude-plugin/plugin.json | 7 +- .../act-gitlab-ci/.codex-plugin/plugin.json | 6 +- plugins/act-gitlab-ci/README.md | 81 +++------- plugins/act-gitlab-ci/plugin.json | 4 +- .../code-reviews/.claude-plugin/plugin.json | 25 +++ .../code-reviews/.codex-plugin/plugin.json | 7 + plugins/code-reviews/README.md | 152 ++++++++++++++++++ .../commands/review-mr.md | 2 +- .../commands/setup-mr-review.md | 10 +- plugins/code-reviews/plugin.json | 6 + .../scripts/ai-review.sh | 0 .../scripts/post-mr-review.ts | 4 +- .../mr-review/fixtures/claude-output.json | 0 .../mr-review/fixtures/discussions-stale.json | 2 +- .../mr-review/fixtures/mr-changes-draft.json | 0 .../tests/mr-review/fixtures/mr-changes.json | 0 .../mr-review/fixtures/notes-with-marker.json | 4 +- .../fixtures/notes-without-marker.json | 0 .../tests/mr-review/fixtures/stub-engine.sh | 0 .../mr-review/fixtures/stub-env-probe.sh | 0 .../fixtures/transcript-malformed.ndjson | 0 .../mr-review/fixtures/transcript.ndjson | 0 .../scripts/tests/mr-review/run-tests.sh | 0 .../scripts/tests/mr-review/unit.test.ts | 4 +- .../skills/mr-review-agent/SKILL.md | 26 +-- .../copilot-code-review.instructions.md | 0 .../examples/git-hook-pre-push.sh | 0 .../examples/mr-review-job.yml | 0 .../examples/review-agent.yaml | 0 .../references/copilot-code-review.md | 2 +- .../references/docker-agent-config.md | 2 +- .../references/gitlab-discussions-api.md | 2 +- .../references/review-modes.md | 4 +- .../references/review-rubric.md | 0 .../skills/review-mr/SKILL.md | 0 .../skills/setup-mr-review/SKILL.md | 0 43 files changed, 326 insertions(+), 137 deletions(-) create mode 100644 plugins/code-reviews/.claude-plugin/plugin.json create mode 100644 plugins/code-reviews/.codex-plugin/plugin.json create mode 100644 plugins/code-reviews/README.md rename plugins/{act-gitlab-ci => code-reviews}/commands/review-mr.md (96%) rename plugins/{act-gitlab-ci => code-reviews}/commands/setup-mr-review.md (91%) create mode 100644 plugins/code-reviews/plugin.json rename plugins/{act-gitlab-ci => code-reviews}/scripts/ai-review.sh (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/post-mr-review.ts (99%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/claude-output.json (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/discussions-stale.json (81%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/mr-changes-draft.json (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/mr-changes.json (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/notes-with-marker.json (60%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/notes-without-marker.json (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/stub-engine.sh (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/stub-env-probe.sh (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/fixtures/transcript.ndjson (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/run-tests.sh (100%) rename plugins/{act-gitlab-ci => code-reviews}/scripts/tests/mr-review/unit.test.ts (98%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/SKILL.md (82%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/examples/copilot-code-review.instructions.md (100%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/examples/git-hook-pre-push.sh (100%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/examples/mr-review-job.yml (100%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/examples/review-agent.yaml (100%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/references/copilot-code-review.md (97%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/references/docker-agent-config.md (98%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/references/gitlab-discussions-api.md (97%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/references/review-modes.md (96%) rename plugins/{act-gitlab-ci => code-reviews}/skills/mr-review-agent/references/review-rubric.md (100%) rename plugins/{act-gitlab-ci => code-reviews}/skills/review-mr/SKILL.md (100%) rename plugins/{act-gitlab-ci => code-reviews}/skills/setup-mr-review/SKILL.md (100%) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index bdd2996..49d31dc 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -5,6 +5,7 @@ { "name": "act-plugin-dev", "source": { "source": "local", "path": "./plugins/act-plugin-dev" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Development" }, { "name": "act-platform-engineering", "source": { "source": "local", "path": "./plugins/act-platform-engineering" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Operations" }, { "name": "act-work-tracking", "source": { "source": "local", "path": "./plugins/act-work-tracking" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Workflow" }, - { "name": "act-gitlab-ci", "source": { "source": "local", "path": "./plugins/act-gitlab-ci" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Engineering" } + { "name": "act-gitlab-ci", "source": { "source": "local", "path": "./plugins/act-gitlab-ci" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Engineering" }, + { "name": "code-reviews", "source": { "source": "local", "path": "./plugins/code-reviews" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Engineering" } ] } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f1af39c..3434433 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -127,8 +127,8 @@ "name": "act-gitlab-ci", "source": "./plugins/act-gitlab-ci", "displayName": "ACT GitLab CI/CD", - "description": "GitLab CI/CD and GitLab tooling for Claude Code. Seven skills covering automated AI review of every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) with inline discussions, sticky summaries and a tokenless fallback; running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers; the GitLab MCP server with its full tool catalogue and version requirements; the glab CLI; CI troubleshooting; and pipeline standards translated to GitLab and marked as derived. Ships a GitLab MCP server configuration, a pipeline security review agent, a zero-dependency pipeline checker, an MR review wrapper with a git-hook harness, and GitHub Copilot review instructions. Relevant to anyone writing a .gitlab-ci.yml, reviewing merge requests automatically, wiring Claude into a pipeline, connecting to GitLab over MCP, or reviewing a pipeline for credential and scan compliance.", - "version": "0.3.0", + "description": "GitLab CI/CD and GitLab tooling for Claude Code. Six skills covering running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers; the GitLab MCP server with its full tool catalogue and version requirements; the glab CLI; CI troubleshooting; and pipeline standards translated to GitLab and marked as derived. Ships a GitLab MCP server configuration, a pipeline security review agent, and a zero-dependency pipeline checker. Relevant to anyone writing a .gitlab-ci.yml, wiring Claude into a pipeline, connecting to GitLab over MCP, or reviewing a pipeline for credential and scan compliance.", + "version": "0.2.1", "author": { "name": "Daniel Bodnar" }, @@ -144,20 +144,51 @@ "oidc", "bedrock", "vertex", - "code-review", - "merge-requests", - "docker-agent", "devops" ], "relevance": { - "topic": "GitLab CI/CD pipelines, merge request review, and GitLab tooling", + "topic": "GitLab CI/CD pipelines and GitLab tooling", "signals": { "filesRead": [ "**/.gitlab-ci.yml", "**/.gitlab/**", "**/gitlab-ci/**/*.yml", - "**/.gitlab-ci/*.yml", - "**/.github/instructions/**" + "**/.gitlab-ci/*.yml" + ] + } + } + }, + { + "name": "code-reviews", + "source": "./plugins/code-reviews", + "displayName": "Code Reviews", + "description": "Automated AI code review across GitHub, GitLab, and local agents. One review rubric and findings contract drive four surfaces: a GitLab CI job that reviews every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) posting inline discussions and a sticky summary with a tokenless log fallback; a git pre-push hook with an engine-dispatching harness; an in-session review command; and GitHub Copilot native review instructions. Posting is deterministic script code, never an agent tool call, and the reviewer never blocks a merge. Relevant to anyone automating merge request or pull request review, wiring an AI reviewer into a pipeline or git hook, or configuring Copilot code review.", + "version": "0.1.0", + "author": { + "name": "Daniel Bodnar" + }, + "license": "LicenseRef-ACT-Internal", + "category": "engineering", + "keywords": [ + "code-review", + "merge-requests", + "pull-requests", + "gitlab", + "github", + "docker-agent", + "git-hooks", + "copilot", + "ci-cd", + "automation" + ], + "relevance": { + "topic": "automated code review of merge requests and pull requests", + "signals": { + "filesRead": [ + "**/.gitlab/ai-review/**", + "**/.github/instructions/**", + "**/.gitlab-ci.yml", + "**/hooks/pre-push*" ] } } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 80fadd7..a93eba6 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -6,6 +6,7 @@ { "name": "act-plugin-dev", "description": "Create and review portable agent plugins.", "version": "0.2.0", "source": "./plugins/act-plugin-dev", "category": "Development" }, { "name": "act-platform-engineering", "description": "Assess and operate PostgreSQL, ZFS, Linux, Proxmox VE, and observability systems.", "version": "0.2.0", "source": "./plugins/act-platform-engineering", "category": "Operations" }, { "name": "act-work-tracking", "description": "Draft Zoho Projects work and engineering status reports.", "version": "0.2.0", "source": "./plugins/act-work-tracking", "category": "Workflow" }, - { "name": "act-gitlab-ci", "description": "Build, review, and troubleshoot GitLab CI/CD integrations, with automated AI merge-request review.", "version": "0.3.0", "source": "./plugins/act-gitlab-ci", "category": "Engineering" } + { "name": "act-gitlab-ci", "description": "Build, review, and troubleshoot GitLab CI/CD integrations.", "version": "0.2.1", "source": "./plugins/act-gitlab-ci", "category": "Engineering" }, + { "name": "code-reviews", "description": "Run automated AI code review on merge requests, pull requests, git hooks, and in-session.", "version": "0.1.0", "source": "./plugins/code-reviews", "category": "Engineering" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 436c42d..f38c7a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,5 +97,5 @@ Do not "fix" these without asking; they are recorded gaps, not oversights. - `docs/assets/` holds an **invented placeholder** wordmark. No ACT brand assets exist. - `.gitlab/` is empty. No ACT GitLab CI conventions were available to base a pipeline on. - `plugins/gitlab-standards/` is a `claude plugin init` scaffold, correctly left unregistered. -- `plugins/code-review/`, `plugins/standards/`, `plugins/git-workflows/` are empty shells. +- `plugins/standards/` and `plugins/git-workflows/` are empty shells. - `LicenseRef-ACT-Internal` is a provisional identifier; ACT's licensing posture is unconfirmed. diff --git a/README.md b/README.md index 41d396a..f83aaae 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ copilot plugin install act-plugin-dev@actdata-plugins | **[`act-plugin-dev`](plugins/act-plugin-dev/)**
Development | Build and review portable plugins while retaining host-specific guidance for commands, agents, hooks, and MCP. | 11 skills · 3 agents · 1 command | | **[`act-platform-engineering`](plugins/act-platform-engineering/)**
Operations | Assessment and operations for PostgreSQL, ZFS, Linux hosts and Proxmox VE. | 23 skills · 7 agents · 9 commands | | **[`act-work-tracking`](plugins/act-work-tracking/)**
Workflow | Zoho Projects work tracking and operations reporting. | 6 skills · 1 agent · 3 commands | -| **[`act-gitlab-ci`](plugins/act-gitlab-ci/)**
Engineering | GitLab CI/CD jobs, automated MR review with swappable AI engines, MCP, authentication, troubleshooting, and pipeline standards. | 13 skills · 1 agent · 5 commands · 1 MCP | +| **[`act-gitlab-ci`](plugins/act-gitlab-ci/)**
Engineering | GitLab CI/CD jobs, MCP, authentication, troubleshooting, and pipeline standards. | 10 skills · 1 agent · 3 commands · 1 MCP | +| **[`code-reviews`](plugins/code-reviews/)**
Engineering | Automated AI code review across GitHub, GitLab, and local agents: CI merge-request review with swappable engines, a pre-push hook, an in-session command, and Copilot review instructions. | 3 skills · 2 commands · 2 scripts | ### Not yet shipped @@ -108,7 +109,6 @@ therefore not installable. That is deliberate — an unfinished plugin should no | Directory | State | |---|---| -| `code-review` | Empty shell. | | `standards` | Empty shell. | | `git-workflows` | Empty shell. | diff --git a/docs/decisions/0005-mr-review-engine-agnostic.md b/docs/decisions/0005-mr-review-engine-agnostic.md index 9c83cc1..465772a 100644 --- a/docs/decisions/0005-mr-review-engine-agnostic.md +++ b/docs/decisions/0005-mr-review-engine-agnostic.md @@ -1,4 +1,4 @@ -# 5. Ship automated MR review as an engine-agnostic contract inside act-gitlab-ci +# 5. Ship automated code review as an engine-agnostic contract in a standalone code-reviews plugin - **Status:** Accepted - **Date:** 2026-08-15 @@ -16,8 +16,9 @@ tri-runtime manifests). Two structural questions followed: 1. **Where does it live?** ADR 0003 split the operational bundle into three plugins by topic. - MR review is a new capability that touches GitLab CI (its primary surface) but also ships a - GitHub artifact. + The capability was first built as an extension of `act-gitlab-ci`; once it spanned GitHub, + GitLab, and local surfaces, that home was revisited the same day and the capability moved to + its own plugin before anything merged. 2. **What is the unit of reuse?** A docker-agent job, a Claude Code job, a pre-push hook, and a Copilot instructions file cannot share code. They can share judgment. @@ -32,15 +33,15 @@ Two facts constrain any GitLab design: | Option | Assessment | |---|---| -| **A fifth plugin (`act-code-review`)** | Honest about the GitHub artifact. Rejected: it would duplicate act-gitlab-ci's CI knowledge (tokens, triggers, cost bounds), and its GitLab surface — the primary one — would depend on a sibling plugin, which `CONTRIBUTING.md` forbids referencing across plugin directories. | -| **Extend act-gitlab-ci** | Chosen. The primary surface is GitLab CI, the plugin already owns Claude-in-CI and pipeline review, and the marketplace's tri-runtime manifests already publish it to Copilot and Codex hosts. The GitHub Copilot instructions template is a recorded tension: a `.github/` artifact in a GitLab-named plugin. If the capability outgrows this home, splitting it out is a version bump, and this ADR is where that revisit starts. | +| **Extend act-gitlab-ci** | The original build target: the primary surface is GitLab CI and the plugin owns the CI knowledge. Rejected on revisit: the capability ships GitHub artifacts (`.github/instructions/`), a host-neutral rubric, and local-agent surfaces — a `.gitlab`-named plugin carrying them misleads, and the review machinery shares no files with the pipeline tooling. | +| **A standalone `code-reviews` plugin** | Chosen. The contract is the unit of cohesion, the plugin is host-neutral by construction, and cross-plugin file references (forbidden by `CONTRIBUTING.md`) never arise because the review bundle is self-contained. | | **The agent posts its own comments (fetch toolset, GitLab MCP, or glab)** | Rejected. It hands the GitLab token to a process parsing untrusted input; the discussions API's position contract is exacting and a model gets it wrong at a steady rate; the HTTP MCP server authenticates over OAuth and is unusable in CI; and none of it is testable without live GitLab. | | **A two-stage contract: engines produce findings JSON, a deterministic script posts** | Chosen. The rubric plus findings schema (`review-rubric.md`) is the stable center; engines (docker-agent, claude, codex, copilot, or any command) are adapters; one wrapper owns every side effect and is pinned by a fixture test suite. | -| **Claude Code as the only engine** | Rejected. It collapses the provider-agnostic requirement to one vendor, and the plugin already documents Claude-in-CI as an *actor* (`claude-code-ci-jobs`); conflating actor and reviewer in one job blurs the security boundary between a read-only process and one that commits. | +| **Claude Code as the only engine** | Rejected. It collapses the provider-agnostic requirement to one vendor, and act-gitlab-ci already documents Claude-in-CI as an *actor*; conflating actor and reviewer in one job blurs the security boundary between a read-only process and one that commits. | ## Decision -Extend `act-gitlab-ci` (0.2.0 to 0.3.0) with an engine-agnostic review capability: +Ship `plugins/code-reviews/` (0.1.0), an engine-agnostic review capability: **1. One contract.** `skills/mr-review-agent/references/review-rubric.md` defines what a reviewer reports, the severity scale, and the findings JSON schema. Every surface derives from it, and the @@ -53,27 +54,36 @@ the default), `summary`, and `log` — the automatic fallback when no `GITLAB_TO because `CI_JOB_TOKEN` cannot post. Positions GitLab rejects (HTTP 400) degrade per finding to plain notes rather than being dropped. -**3. The engine is confined.** Read-only toolsets in the shipped docker-agent config; the wrapper -strips `GITLAB_TOKEN` from the engine's environment; timeouts, `allow_failure: true`, +**3. The engine is confined.** Read-only toolsets in the shipped docker-agent config; both +scripts strip every GitLab token from the engine's environment; timeouts, `allow_failure: true`, `interruptible: true`, diff budgets, and turn caps bound cost. The reviewer never blocks a merge. **4. Scripts are copied into target repositories** (`.gitlab/ai-review/`) by the `setup-mr-review` command, because CI jobs cannot resolve `${CLAUDE_PLUGIN_ROOT}`. The canonical, tested copies stay in the plugin; re-running the command refreshes them. -**5. The docker-agent binary is version-pinned** (`DOCKER_AGENT_VERSION`) and downloaded at job -runtime — the repository tracks no binaries. Despite the name, no Docker daemon is involved; the -binary is standalone, which is what makes the "runs in a normal pipeline" requirement hold even -for the default engine. +**5. The docker-agent binary is version-pinned** (`DOCKER_AGENT_VERSION`, optional +`DOCKER_AGENT_SHA256`) and downloaded at job runtime — the repository tracks no binaries. Despite +the name, no Docker daemon is involved; the binary is standalone, which is what makes the "runs +in a normal pipeline" requirement hold even for the default engine. + +**6. Naming.** The plugin is `code-reviews`, by explicit request — the one departure from the +`act-*` prefix convention. The plural also avoids shadowing the upstream Anthropic `code-review` +plugin, whose `/code-review:code-review` command this repository's own GitHub workflow invokes. +The empty `plugins/code-review/` scaffold shell was removed with this change. ## Consequences -**A GitHub artifact ships in a GitLab-named plugin.** `copilot-code-review.instructions.md` and -the `copilot` setup surface sit in `act-gitlab-ci` because the review contract, not the host, is -the unit of cohesion. The tension is real and recorded here; a future `act-code-review` split -would move the rubric and its restatements together. +**The act-* naming convention now has one exception.** Every other plugin keeps the prefix; a +future rename to `act-code-reviews` would be a breaking change for installed consumers and should +not be done casually. + +**act-gitlab-ci stays a pipeline-tooling plugin.** Its only change from this work is a +model-identifier refresh in two examples (0.2.1). Its `claude-code-ci-jobs` skill remains the +actor-in-CI integration; `code-reviews` is the reviewer. The skills state the boundary in both +directions, and neither plugin requires the other. -**The token guidance now has a documented exception.** `claude-code-ci-jobs` says to prefer +**The token guidance now has a documented exception.** act-gitlab-ci says to prefer `CI_JOB_TOKEN`; posting review comments is a case its permissions cannot cover, and the `mr-review-agent` skill states when each applies. Without a project access token the capability degrades to `log` mode rather than failing. @@ -81,9 +91,9 @@ degrades to `log` mode rather than failing. **Two engines are tested, two are best-effort.** The fixture suite exercises docker-agent's and claude's output envelopes (saved transcripts; no engine is ever spawned in tests); codex and copilot CLI flags are young and verified only at setup time, with `AI_REVIEW_ENGINE_CMD` as the -escape hatch. The suite (`scripts/tests/mr-review/`) runs with no -network and no engines, so the repository gate pins parsing, positioning, the 400 fallback, mode -downgrades, marker stickiness, and re-push resolution — not model quality. +escape hatch. The suite (`scripts/tests/mr-review/`) runs with no network and no engines, so the +repository gate pins parsing, positioning, the 400 fallback, mode downgrades, marker stickiness, +token stripping, and re-push resolution — not model quality. **The rubric's restatements can drift.** The Copilot instructions file is a manual restatement of the rubric, and nothing mechanical keeps them aligned; the skill instructs that they change diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8361940..c3f611c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -15,7 +15,7 @@ what it cost to arrive at, needs one. | [0002](0002-config-driven-plugins.md) | Ship no environment identifiers; read them from a site-local settings file | Accepted | | [0003](0003-three-plugin-split.md) | Split the operational bundle into three plugins | Accepted | | [0004](0004-derived-pipeline-standards.md) | Ship pipeline standards as explicitly derived, and record the platform conflict | Accepted | -| [0005](0005-mr-review-engine-agnostic.md) | Ship automated MR review as an engine-agnostic contract inside act-gitlab-ci | Accepted | +| [0005](0005-mr-review-engine-agnostic.md) | Ship automated code review as an engine-agnostic contract in a standalone code-reviews plugin | Accepted | ## Format diff --git a/plugins/act-gitlab-ci/.claude-plugin/plugin.json b/plugins/act-gitlab-ci/.claude-plugin/plugin.json index cf27ad9..4701b55 100644 --- a/plugins/act-gitlab-ci/.claude-plugin/plugin.json +++ b/plugins/act-gitlab-ci/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "act-gitlab-ci", - "version": "0.3.0", - "description": "GitLab CI/CD and GitLab tooling for Claude Code. Covers automated AI review of merge requests through swappable engines (docker-agent, Claude Code, Codex, Copilot), running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers, the GitLab MCP server, the glab CLI, and pipeline standards adapted for GitLab. Includes a zero-dependency pipeline validator, an MR review wrapper with a git-hook harness, and GitHub Copilot review instructions.", + "version": "0.2.1", + "description": "GitLab CI/CD and GitLab tooling for Claude Code. Covers running Claude Code as a CI job across the Claude API, Amazon Bedrock and Vertex providers, the GitLab MCP server, the glab CLI, and pipeline standards adapted for GitLab. Includes a zero-dependency pipeline validator.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com" @@ -20,9 +20,6 @@ "oidc", "bedrock", "vertex", - "code-review", - "merge-requests", - "docker-agent", "devops" ] } diff --git a/plugins/act-gitlab-ci/.codex-plugin/plugin.json b/plugins/act-gitlab-ci/.codex-plugin/plugin.json index 52d04b2..97baeb1 100644 --- a/plugins/act-gitlab-ci/.codex-plugin/plugin.json +++ b/plugins/act-gitlab-ci/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "act-gitlab-ci", "version": "0.3.0", "description": "GitLab CI/CD, automated merge-request review, MCP, authentication, troubleshooting, and pipeline review workflows for ACT Data.", + "name": "act-gitlab-ci", "version": "0.2.1", "description": "GitLab CI/CD, MCP, authentication, troubleshooting, and pipeline review workflows for ACT Data.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", - "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "code-review", "devops"], "skills": "./skills/", "mcpServers": "./.mcp.json", - "interface": { "displayName": "ACT GitLab CI/CD", "shortDescription": "Build, review, and troubleshoot GitLab pipelines", "longDescription": "Configure GitLab CI agent jobs, set up automated AI review of merge requests, connect GitLab MCP, review pipeline controls, and troubleshoot authentication and execution failures.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Review this GitLab pipeline against ACT standards.", "Set up automated AI review of merge requests.", "Help me connect the GitLab MCP server."] } + "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "devops"], "skills": "./skills/", "mcpServers": "./.mcp.json", + "interface": { "displayName": "ACT GitLab CI/CD", "shortDescription": "Build, review, and troubleshoot GitLab pipelines", "longDescription": "Configure GitLab CI agent jobs, connect GitLab MCP, review pipeline controls, and troubleshoot authentication and execution failures.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Review this GitLab pipeline against ACT standards.", "Help me connect the GitLab MCP server."] } } diff --git a/plugins/act-gitlab-ci/README.md b/plugins/act-gitlab-ci/README.md index 0e8d9b8..c700aba 100644 --- a/plugins/act-gitlab-ci/README.md +++ b/plugins/act-gitlab-ci/README.md @@ -7,11 +7,11 @@ # act-gitlab-ci -GitLab CI/CD, automated merge-request review, the GitLab MCP server, the `glab` CLI, and pipeline standards. +GitLab CI/CD, the GitLab MCP server, the `glab` CLI, and pipeline standards. -![skills](https://img.shields.io/badge/skills-13-00A8E1?labelColor=003767) +![skills](https://img.shields.io/badge/skills-10-00A8E1?labelColor=003767) ![agents](https://img.shields.io/badge/agents-1-003767) -![commands](https://img.shields.io/badge/commands-5-147EC2) +![commands](https://img.shields.io/badge/commands-3-147EC2) ![mcp](https://img.shields.io/badge/mcp-gitlab-00817D) ![deps](https://img.shields.io/badge/dependencies-none-58585B) @@ -27,7 +27,6 @@ GitLab CI/CD, automated merge-request review, the GitLab MCP server, the `glab` - [Commands](#commands) - [Install](#install) - [Configuration](#configuration) -- [Automated MR review](#automated-mr-review) - [The pipeline checker](#the-pipeline-checker) - [Standards provenance](#standards-provenance) - [What this plugin does NOT do](#what-this-plugin-does-not-do) @@ -35,15 +34,14 @@ GitLab CI/CD, automated merge-request review, the GitLab MCP server, the `glab` ## What this is -Five related things: +Four related things: -1. **Automated AI review of every merge request**, engine-agnostic (docker-agent, Claude Code, - Codex, Copilot), with the same rubric available as a git hook, an in-session command, and - GitHub Copilot review instructions. -2. **Running Claude Code as a GitLab CI job**, across the Claude API, Amazon Bedrock and Google Cloud. -3. **The GitLab MCP server**, for interactive sessions. -4. **The `glab` CLI**, which is often the better tool for scripted GitLab work. -5. **Pipeline standards**, translated to GitLab and marked as derived throughout. +1. **Running Claude Code as a GitLab CI job**, across the Claude API, Amazon Bedrock and Google Cloud. +2. **The GitLab MCP server**, for interactive sessions. +3. **The `glab` CLI**, which is often the better tool for scripted GitLab work. +4. **Pipeline standards**, translated to GitLab and marked as derived throughout. + +For automated AI review of merge requests, see the `code-reviews` plugin in this marketplace. > [!IMPORTANT] > **Two different things are called the GitLab MCP server.** The HTTP server at @@ -55,17 +53,16 @@ Five related things: | Component | Count | What it is | |---|---|---| -| Skills | 13 | MR review, CI jobs, auth providers, MCP server, `glab`, troubleshooting, standards, and portable command/agent adapters | +| Skills | 10 | CI jobs, auth providers, MCP server, `glab`, troubleshooting, standards, and portable command/agent adapters | | Agents | 1 | Pipeline security review | -| Commands | 5 | Set up MR review, review an MR, set up the Claude job, review a pipeline, connect MCP | +| Commands | 3 | Set up the job, review a pipeline, connect MCP | | MCP servers | 1 | GitLab, over HTTP | -| Scripts | 3 | Zero-dependency pipeline checker, MR review wrapper, and engine-dispatch harness, with two fixture-driven test suites | +| Scripts | 1 | Zero-dependency pipeline checker with 23 tests | ## Skills | Skill | What it covers | |---|---| -| [`mr-review-agent`](skills/mr-review-agent/) | Automated MR review: the rubric and findings contract, engines, delivery modes, tokens, re-push semantics. Templates for the CI job, the docker-agent config, a pre-push hook and Copilot instructions. | | [`claude-code-ci-jobs`](skills/claude-code-ci-jobs/) | Job definition, trigger rules, `AI_FLOW_*`, CLI flags, cost bounds. Three complete job examples. | | [`ci-auth-providers`](skills/ci-auth-providers/) | Claude API, Bedrock over OIDC, Google Cloud over WIF | | [`gitlab-mcp-server`](skills/gitlab-mcp-server/) | Enabling, connecting, ~26 tools with version requirements, security | @@ -77,8 +74,6 @@ Five related things: | Command | Does | |---|---| -| `/act-gitlab-ci:setup-mr-review` | Install automated review as a CI MR job, a git hook, or Copilot instructions | -| `/act-gitlab-ci:review-mr` | Review one merge request in-session against the shared rubric | | `/act-gitlab-ci:setup-claude-job` | Add a Claude Code job with a provider, trigger rules and cost bounds | | `/act-gitlab-ci:review-pipeline` | Review a pipeline, separating verified findings from what cannot be checked | | `/act-gitlab-ci:connect-gitlab-mcp` | Connect the MCP server, checking prerequisites first | @@ -124,33 +119,6 @@ Minimum GitLab 18.6 for beta. Free, Premium and Ultimate all qualify. > than the catalogue lists. Check `skills/gitlab-mcp-server/references/tool-catalogue.md` before > concluding a tool is broken. -## Automated MR review - -One review rubric and findings contract, four ways to run it: - -| Surface | How | Setup | -|---|---|---| -| GitLab CI, every MR | A job runs an AI engine over the diff and posts the review | `/act-gitlab-ci:setup-mr-review gitlab-ci` | -| Git hook / scripts | `ai-review.sh` reviews a local diff with whatever engine is on PATH | `/act-gitlab-ci:setup-mr-review git-hook` | -| In-session | `/act-gitlab-ci:review-mr` on any host the plugin is installed in | none | -| GitHub Copilot native review | A `.github/instructions/` file carries the rubric | `/act-gitlab-ci:setup-mr-review copilot` | - -Engines are swappable (`AI_REVIEW_ENGINE`): `docker-agent` (default, provider-agnostic, a pinned -standalone binary -- no Docker daemon), `claude`, `codex`, `copilot`, or any command via -`AI_REVIEW_ENGINE_CMD`. Delivery is mode-selected (`AI_REVIEW_MODE`): `inline` posts one -positioned discussion per finding plus a sticky summary, `summary` posts the note alone, `log` -writes to the job log and artifacts. - -> [!IMPORTANT] -> Posting MR comments requires a project access token (`GITLAB_TOKEN`, `api` scope, masked): -> `CI_JOB_TOKEN` cannot create notes. Without one, the job automatically falls back to `log` -> mode. The reviewer never blocks a merge (`allow_failure: true`), and the engine never sees the -> GitLab token -- the diff it reviews is untrusted input. Do not expose the token or the model -> API key to pipelines from forks. - -See [`skills/mr-review-agent/`](skills/mr-review-agent/) for the rubric, mode and engine -matrices, and the security model. - ## The pipeline checker ```sh @@ -188,20 +156,14 @@ period, no coverage threshold and no scan severity gate. ## What this plugin does NOT do > [!CAUTION] -> `setup-claude-job` and `setup-mr-review` write to your repository (`.gitlab-ci.yml`, -> `.gitlab/ai-review/`, `.github/instructions/`, hooks). Everything else is read-only, and -> `review-mr` posts to an MR only on explicit confirmation. +> `setup-claude-job` writes to your `.gitlab-ci.yml`. Everything else is read-only. -- **No credential handling.** It never reads, writes or prompts for a token or model API key. - Variables are set in the GitLab UI, which the plugin tells you to do and cannot do for you. +- **No credential handling.** It never reads, writes or prompts for a token. Variables are set in the + GitLab UI, which the plugin tells you to do and cannot do for you. - **No webhook setup.** `@claude` mentions need a listener calling the pipeline trigger API. GitLab - does not do this natively and this plugin does not build it. MR-event triggering is native and - is what the review job uses. -- **The reviewer never gates a merge.** The review job runs with `allow_failure: true`, and the - pre-push hook is advisory unless `AI_REVIEW_BLOCKING=1`. Generated findings are advice to - verify, not policy. -- **Copilot native reviews are configured, not executed.** The instructions file only takes - effect where Copilot code review is enabled on the GitHub side. + does not do this natively and this plugin does not build it. +- **No automated MR review.** That lives in the `code-reviews` plugin, which runs an AI reviewer in + the pipeline and posts findings to the merge request. - **No authority on standards.** See above. It reports derived rules as derived. - **No GitLab administration.** Enabling Duo, beta features and MCP access are admin or group-owner actions. @@ -216,14 +178,11 @@ act-gitlab-ci/ README.md agents/pipeline-security-reviewer.md commands/ - setup-mr-review.md review-mr.md setup-claude-job.md review-pipeline.md connect-gitlab-mcp.md scripts/ - check-pipeline.ts post-mr-review.ts ai-review.sh + check-pipeline.ts tests/ run-tests.sh compliant/ violating/ - tests/mr-review/ run-tests.sh unit.test.ts fixtures/ skills/ - mr-review-agent/ SKILL.md + references/(5) + examples/(4) claude-code-ci-jobs/ SKILL.md + examples/(3 yml) ci-auth-providers/ SKILL.md gitlab-mcp-server/ SKILL.md + references/(2) diff --git a/plugins/act-gitlab-ci/plugin.json b/plugins/act-gitlab-ci/plugin.json index f5c11ce..960ee81 100644 --- a/plugins/act-gitlab-ci/plugin.json +++ b/plugins/act-gitlab-ci/plugin.json @@ -1,6 +1,6 @@ { - "name": "act-gitlab-ci", "version": "0.3.0", "description": "Build, review, and troubleshoot GitLab CI/CD integrations, with automated AI merge-request review.", + "name": "act-gitlab-ci", "version": "0.2.1", "description": "Build, review, and troubleshoot GitLab CI/CD integrations.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", - "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "code-review", "devops"], "skills": "skills/", "commands": "commands/", "mcpServers": ".mcp.json" + "keywords": ["gitlab", "gitlab-ci", "pipelines", "mcp", "oidc", "devops"], "skills": "skills/", "commands": "commands/", "mcpServers": ".mcp.json" } diff --git a/plugins/code-reviews/.claude-plugin/plugin.json b/plugins/code-reviews/.claude-plugin/plugin.json new file mode 100644 index 0000000..96f9f49 --- /dev/null +++ b/plugins/code-reviews/.claude-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "code-reviews", + "version": "0.1.0", + "description": "Automated AI code review across GitHub, GitLab, and local agents. One review rubric and findings contract drive four surfaces: a GitLab CI job that reviews every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) with inline discussions and a sticky summary, a git pre-push hook, an in-session review command, and GitHub Copilot native review instructions. Delivery is deterministic script code, never an agent tool call, with a tokenless log-mode fallback.", + "author": { + "name": "Daniel Bodnar", + "email": "dbodnar@pattersonvet.com" + }, + "homepage": "https://github.com/patterson-agents/actdata-plugins", + "repository": "https://github.com/patterson-agents/actdata-plugins", + "license": "LicenseRef-ACT-Internal", + "keywords": [ + "code-review", + "merge-requests", + "pull-requests", + "gitlab", + "github", + "docker-agent", + "git-hooks", + "copilot", + "ci-cd", + "automation" + ] +} diff --git a/plugins/code-reviews/.codex-plugin/plugin.json b/plugins/code-reviews/.codex-plugin/plugin.json new file mode 100644 index 0000000..29643e6 --- /dev/null +++ b/plugins/code-reviews/.codex-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "code-reviews", "version": "0.1.0", "description": "Automated AI code review for merge requests, pull requests, git hooks, and interactive sessions.", + "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, + "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", + "keywords": ["code-review", "merge-requests", "pull-requests", "gitlab", "github", "automation"], "skills": "./skills/", + "interface": { "displayName": "Code Reviews", "shortDescription": "Automated AI code review for MRs, PRs, hooks, and sessions", "longDescription": "Install an engine-agnostic AI reviewer: a GitLab CI job posting inline merge-request discussions, a git pre-push hook, an in-session review command, and GitHub Copilot native review instructions, all driven by one rubric and findings contract.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Set up automated AI review of merge requests.", "Review this merge request against the shared rubric."] } +} diff --git a/plugins/code-reviews/README.md b/plugins/code-reviews/README.md new file mode 100644 index 0000000..5672e76 --- /dev/null +++ b/plugins/code-reviews/README.md @@ -0,0 +1,152 @@ +
+ + + + ACT Data + + +# code-reviews + +Automated AI code review across GitHub, GitLab, and local agents. + +![skills](https://img.shields.io/badge/skills-3-00A8E1?labelColor=003767) +![agents](https://img.shields.io/badge/agents-0-003767) +![commands](https://img.shields.io/badge/commands-2-147EC2) +![scripts](https://img.shields.io/badge/scripts-2-00817D) +![deps](https://img.shields.io/badge/dependencies-none-58585B) + +
+ +--- + +## Table of contents + +- [What this is](#what-this-is) +- [What ships](#what-ships) +- [Skills](#skills) +- [Commands](#commands) +- [Install](#install) +- [How a review runs](#how-a-review-runs) +- [Configuration](#configuration) +- [Security model](#security-model) +- [What this plugin does NOT do](#what-this-plugin-does-not-do) +- [Layout](#layout) + +## What this is + +One review rubric and one findings contract, delivered on four surfaces: + +| Surface | How | Setup | +|---|---|---| +| GitLab CI, every MR | A job runs an AI engine over the diff and posts the review | `/code-reviews:setup-mr-review gitlab-ci` | +| Git hook / scripts | `ai-review.sh` reviews a local diff with whatever engine is on PATH | `/code-reviews:setup-mr-review git-hook` | +| In-session | `/code-reviews:review-mr` on any host the plugin is installed in | none | +| GitHub Copilot native review | A `.github/instructions/` file carries the rubric | `/code-reviews:setup-mr-review copilot` | + +Engines are swappable (`AI_REVIEW_ENGINE`): `docker-agent` (default, provider-agnostic, a pinned +standalone binary -- no Docker daemon), `claude`, `codex`, `copilot`, or any command via +`AI_REVIEW_ENGINE_CMD`. The engine only ever produces findings; deterministic script code does +all posting. + +## What ships + +| Component | Count | What it is | +|---|---|---| +| Skills | 3 | The review knowledge skill plus portable adapters for both commands | +| Commands | 2 | Install a review surface; review one MR in-session | +| Scripts | 2 | The CI wrapper (`post-mr-review.ts`) and the engine-dispatch harness (`ai-review.sh`), with a fixture-driven test suite | +| Templates | 4 | CI job, docker-agent config, Copilot instructions file, pre-push hook | + +## Skills + +| Skill | What it covers | +|---|---| +| [`mr-review-agent`](skills/mr-review-agent/) | The rubric and findings contract, engines, delivery modes, tokens, re-push semantics. Five references, four templates. | + +Plus `setup-mr-review` and `review-mr` adapter skills, which make both commands reachable from +non-Claude hosts. + +## Commands + +| Command | Does | +|---|---| +| `/code-reviews:setup-mr-review` | Install automated review as a CI MR job, a git hook, or Copilot instructions | +| `/code-reviews:review-mr` | Review one merge request in-session against the shared rubric | + +## Install + +```sh +claude plugin marketplace add patterson-agents/actdata-plugins +claude plugin install code-reviews@actdata-plugins +``` + +## How a review runs + +1. The wrapper collects the diff -- from the GitLab API in comment modes, from local git in + tokenless mode -- and caps it with per-file and total budgets. Truncations are named in the + summary so a partial review never poses as a full one. +2. The selected engine reviews the diff against the rubric and emits the findings contract: + `{summary, findings: [{path, new_line, old_line, severity, title, body}]}`. +3. Delivery per `AI_REVIEW_MODE`: + +| Mode | Requires | Result | +|---|---|---| +| `inline` (default) | `GITLAB_TOKEN` | A positioned discussion per finding plus a sticky summary note; positions GitLab rejects degrade to plain notes. On re-push, stale bot threads are resolved and the summary updates in place. | +| `summary` | `GITLAB_TOKEN` | The sticky summary note only. | +| `log` | nothing | Job log plus artifacts. Automatic fallback when `GITLAB_TOKEN` is unset. | + +The reviewer never blocks a merge: the job runs `allow_failure: true`, and the pre-push hook is +advisory unless `AI_REVIEW_BLOCKING=1`. + +## Configuration + +CI/CD variables, all masked, created by the user (the plugin never handles values): + +| Variable | Required | Purpose | +|---|---|---| +| Provider API key (e.g. `ANTHROPIC_API_KEY`) | yes | Whatever key the chosen engine's `model:` needs | +| `GITLAB_TOKEN` | for `inline`/`summary` | Project access token, `api` scope, Developer role. `CI_JOB_TOKEN` cannot create MR notes. | +| `AI_REVIEW_MODE`, `AI_REVIEW_ENGINE` | no | Defaults: `inline`, `docker-agent` | +| `DOCKER_AGENT_VERSION`, `DOCKER_AGENT_SHA256` | no | Pinned binary release and optional checksum | + +## Security model + +> [!CAUTION] +> The diff under review is untrusted input to an unattended model. The shipped docker-agent config +> is read-only (no shell, no network, no MCP); both scripts strip `GITLAB_TOKEN`, +> `GITLAB_ACCESS_TOKEN`, and `CI_JOB_TOKEN` from every engine's environment; posting is +> deterministic script code, never an agent tool call. Do not expose the GitLab token or the model +> API key to pipelines from forks. + +## What this plugin does NOT do + +> [!CAUTION] +> `setup-mr-review` writes to your repository (`.gitlab-ci.yml`, `.gitlab/ai-review/`, +> `.github/instructions/`, hooks). `review-mr` posts to an MR only on explicit confirmation. + +- **No credential handling.** Variables are set in the GitLab or GitHub UI; the plugin tells the + user which to create and cannot create them itself. +- **The reviewer never gates a merge.** Generated findings are advice to verify, not policy. +- **Copilot native reviews are configured, not executed.** The instructions file only takes effect + where Copilot code review is enabled on the GitHub side. +- **No webhook or mention-driven triggering.** MR-event pipelines are native GitLab behavior; + comment-driven triggering needs a listener this plugin does not build. +- **No pipeline standards authority.** GitLab pipeline security review lives in the + `act-gitlab-ci` plugin; neither plugin requires the other. + +## Layout + +```text +code-reviews/ + .claude-plugin/plugin.json + README.md + commands/ + setup-mr-review.md review-mr.md + scripts/ + post-mr-review.ts ai-review.sh + tests/mr-review/ run-tests.sh unit.test.ts fixtures/ + skills/ + mr-review-agent/ SKILL.md + references/(5) + examples/(4) + setup-mr-review/ SKILL.md (adapter) + review-mr/ SKILL.md (adapter) +``` diff --git a/plugins/act-gitlab-ci/commands/review-mr.md b/plugins/code-reviews/commands/review-mr.md similarity index 96% rename from plugins/act-gitlab-ci/commands/review-mr.md rename to plugins/code-reviews/commands/review-mr.md index 8e2dc51..82554ae 100644 --- a/plugins/act-gitlab-ci/commands/review-mr.md +++ b/plugins/code-reviews/commands/review-mr.md @@ -48,7 +48,7 @@ review says so explicitly. Never pad: an empty findings list is a valid, common After reporting, offer to post the review to the MR -- and only proceed on an explicit yes: - With the glab CLI or a `GITLAB_TOKEN` available, post one summary note. Append the marker line - `` so the CI job's sticky-note + `` so the CI job's sticky-note logic recognizes and updates it instead of duplicating it. Say plainly that this marker also makes the CI review job treat the current head as already reviewed and skip its own run (including inline discussions) until the next push -- posting an in-session review supersedes diff --git a/plugins/act-gitlab-ci/commands/setup-mr-review.md b/plugins/code-reviews/commands/setup-mr-review.md similarity index 91% rename from plugins/act-gitlab-ci/commands/setup-mr-review.md rename to plugins/code-reviews/commands/setup-mr-review.md index f657f8b..b1a1a2d 100644 --- a/plugins/act-gitlab-ci/commands/setup-mr-review.md +++ b/plugins/code-reviews/commands/setup-mr-review.md @@ -74,9 +74,7 @@ From the first word of `$ARGUMENTS`, or ask: ## Review before finishing -For the `gitlab-ci` surface, run the pipeline checker over the result and report which findings -came from this change: - -```bash -bun "${CLAUDE_PLUGIN_ROOT}/scripts/check-pipeline.ts" .gitlab-ci.yml -``` +For the `gitlab-ci` surface, re-read the resulting `.gitlab-ci.yml` and confirm the job kept its +bounds (`timeout`, `allow_failure: true`, `interruptible: true`, the draft-skip rules). If the +`act-gitlab-ci` plugin is installed, also run its `/act-gitlab-ci:review-pipeline` command over +the result and report which findings came from this change. diff --git a/plugins/code-reviews/plugin.json b/plugins/code-reviews/plugin.json new file mode 100644 index 0000000..36c63ff --- /dev/null +++ b/plugins/code-reviews/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "code-reviews", "version": "0.1.0", "description": "Run automated AI code review on merge requests, pull requests, git hooks, and in-session.", + "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, + "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", + "keywords": ["code-review", "merge-requests", "pull-requests", "gitlab", "github", "automation"], "skills": "skills/", "commands": "commands/" +} diff --git a/plugins/act-gitlab-ci/scripts/ai-review.sh b/plugins/code-reviews/scripts/ai-review.sh similarity index 100% rename from plugins/act-gitlab-ci/scripts/ai-review.sh rename to plugins/code-reviews/scripts/ai-review.sh diff --git a/plugins/act-gitlab-ci/scripts/post-mr-review.ts b/plugins/code-reviews/scripts/post-mr-review.ts similarity index 99% rename from plugins/act-gitlab-ci/scripts/post-mr-review.ts rename to plugins/code-reviews/scripts/post-mr-review.ts index b94c662..de52ce3 100644 --- a/plugins/act-gitlab-ci/scripts/post-mr-review.ts +++ b/plugins/code-reviews/scripts/post-mr-review.ts @@ -310,10 +310,10 @@ export function buildPrompt( // the newest fallback note be mistaken for the summary and get overwritten. export type MarkerKind = "summary" | "finding"; -const MARKER_RE = //; +const MARKER_RE = //; export function marker(sha: string, kind: MarkerKind): string { - return ``; + return ``; } export function markerShaOf(body: string): string | null { diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/claude-output.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/claude-output.json similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/claude-output.json rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/claude-output.json diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json similarity index 81% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json index d2836ca..1a88ee8 100644 --- a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/discussions-stale.json +++ b/plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json @@ -3,7 +3,7 @@ "id": "d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0", "notes": [ { - "body": "**[Warning]** Stale finding from the previous revision\n\nDetail.\n\n", + "body": "**[Warning]** Stale finding from the previous revision\n\nDetail.\n\n", "resolved": false } ] diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes-draft.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes-draft.json similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes-draft.json rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes-draft.json diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes.json similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/mr-changes.json rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes.json diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json similarity index 60% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json index d4bced4..c509f27 100644 --- a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-with-marker.json +++ b/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json @@ -1,7 +1,7 @@ [ { "id": 60, - "body": "**[Warning]** `src/payments/refund.ts:17` -- Fallback finding from the previous revision\n\nPosted as a plain note because GitLab rejected its diff position.\n\n" + "body": "**[Warning]** `src/payments/refund.ts:17` -- Fallback finding from the previous revision\n\nPosted as a plain note because GitLab rejected its diff position.\n\n" }, { "id": 55, @@ -9,6 +9,6 @@ }, { "id": 101, - "body": "## Automated code review\n\nPrevious review of an earlier revision.\n\nReviewed commit abc123abc123.\n\n" + "body": "## Automated code review\n\nPrevious review of an earlier revision.\n\nReviewed commit abc123abc123.\n\n" } ] diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-without-marker.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-without-marker.json similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/notes-without-marker.json rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-without-marker.json diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-engine.sh b/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-engine.sh similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-engine.sh rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-engine.sh diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-env-probe.sh b/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-env-probe.sh similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/stub-env-probe.sh rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-env-probe.sh diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson b/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript.ndjson b/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript.ndjson similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/fixtures/transcript.ndjson rename to plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript.ndjson diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh b/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh similarity index 100% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/run-tests.sh rename to plugins/code-reviews/scripts/tests/mr-review/run-tests.sh diff --git a/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts b/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts similarity index 98% rename from plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts rename to plugins/code-reviews/scripts/tests/mr-review/unit.test.ts index ccb8686..77745ef 100644 --- a/plugins/act-gitlab-ci/scripts/tests/mr-review/unit.test.ts +++ b/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts @@ -246,8 +246,8 @@ describe("positions and markers", () => { }); test("markers without a kind still yield their sha", () => { - expect(markerShaOf("")).toBe("abc123abc123"); - expect(markerKindOf("")).toBeNull(); + expect(markerShaOf("")).toBe("abc123abc123"); + expect(markerKindOf("")).toBeNull(); }); }); diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md b/plugins/code-reviews/skills/mr-review-agent/SKILL.md similarity index 82% rename from plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md rename to plugins/code-reviews/skills/mr-review-agent/SKILL.md index 13d0edb..87b1fde 100644 --- a/plugins/act-gitlab-ci/skills/mr-review-agent/SKILL.md +++ b/plugins/code-reviews/skills/mr-review-agent/SKILL.md @@ -24,7 +24,7 @@ Engines are swappable via `AI_REVIEW_ENGINE`: | Engine | Runs | Status | |---|---|---| | `docker-agent` (default) | Pinned standalone binary; provider-agnostic `model:` in `review-agent.yaml`. No Docker daemon involved. | Tested pair | -| `claude` | `claude -p` headless with read-only tools | Tested pair | +| `claude` | `claude -p` headless with read-only tools; Bedrock/Vertex auth is covered by the act-gitlab-ci plugin | Tested pair | | `codex` | `codex exec` | Best effort; verify flags per version | | `copilot` | Copilot CLI | Best effort; verify flags per version | @@ -35,10 +35,10 @@ prints the findings JSON. | Surface | Entry point | Setup | |---|---|---| -| GitLab CI on every MR | `examples/mr-review-job.yml` running `post-mr-review.ts` | `/act-gitlab-ci:setup-mr-review gitlab-ci` | -| Git hooks / scripts | `scripts/ai-review.sh`, `examples/git-hook-pre-push.sh` | `/act-gitlab-ci:setup-mr-review git-hook` | +| GitLab CI on every MR | `examples/mr-review-job.yml` running `post-mr-review.ts` | `/code-reviews:setup-mr-review gitlab-ci` | +| Git hooks / scripts | `scripts/ai-review.sh`, `examples/git-hook-pre-push.sh` | `/code-reviews:setup-mr-review git-hook` | | In-session, any host | the `review-mr` command | installed with the plugin | -| GitHub Copilot native review | `examples/copilot-code-review.instructions.md` | `/act-gitlab-ci:setup-mr-review copilot` | +| GitHub Copilot native review | `examples/copilot-code-review.instructions.md` | `/code-reviews:setup-mr-review copilot` | The CI scripts are **copied into the target repository** (conventionally `.gitlab/ai-review/`) because a CI job cannot resolve plugin paths. Re-run the setup command to pick up plugin updates. @@ -59,10 +59,11 @@ because a CI job cannot resolve plugin paths. Re-run the setup command to pick u `inline` and `summary` modes do. Those modes need `GITLAB_TOKEN`: a project access token with `api` scope and Developer role, stored masked. -This is the documented exception to the `claude-code-ci-jobs` guidance to prefer `CI_JOB_TOKEN`. -That guidance stands wherever the job token's permissions suffice (cloning, package registries, -trigger tokens); posting review comments is a case where they do not. Without a project access -token, run `log` mode -- it needs no token at all. +This is the documented exception to the act-gitlab-ci plugin's guidance (in its +`claude-code-ci-jobs` skill) to prefer `CI_JOB_TOKEN`. That guidance stands wherever the job +token's permissions suffice (cloning, package registries, trigger tokens); posting review +comments is a case where they do not. Without a project access token, run `log` mode -- it needs +no token at all. > [!CAUTION] > The diff under review is untrusted input to the model. Keep engine toolsets read-only (the @@ -79,11 +80,12 @@ are listed in the summary), a turn cap where the engine supports one (`AI_REVIEW the `claude` engine), and, in token modes, a no-op guard: a pipeline retry on an already-reviewed head SHA exits before the engine runs. -## Boundary with claude-code-ci-jobs +## Boundary with the act-gitlab-ci plugin -The `claude-code-ci-jobs` skill runs Claude Code in CI as an **actor**: it edits files, commits, -and opens MRs from instructions. This skill runs an engine as a **reviewer**: read-only, structured -findings, deterministic posting. Neither replaces the other; a repository can carry both jobs. +The act-gitlab-ci plugin's `claude-code-ci-jobs` skill runs Claude Code in CI as an **actor**: it +edits files, commits, and opens MRs from instructions. This skill runs an engine as a +**reviewer**: read-only, structured findings, deterministic posting. Neither replaces the other; a +repository can carry both jobs, and neither plugin requires the other. ## Additional resources diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/copilot-code-review.instructions.md b/plugins/code-reviews/skills/mr-review-agent/examples/copilot-code-review.instructions.md similarity index 100% rename from plugins/act-gitlab-ci/skills/mr-review-agent/examples/copilot-code-review.instructions.md rename to plugins/code-reviews/skills/mr-review-agent/examples/copilot-code-review.instructions.md diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/git-hook-pre-push.sh b/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh similarity index 100% rename from plugins/act-gitlab-ci/skills/mr-review-agent/examples/git-hook-pre-push.sh rename to plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml b/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml similarity index 100% rename from plugins/act-gitlab-ci/skills/mr-review-agent/examples/mr-review-job.yml rename to plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/examples/review-agent.yaml b/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml similarity index 100% rename from plugins/act-gitlab-ci/skills/mr-review-agent/examples/review-agent.yaml rename to plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md b/plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md similarity index 97% rename from plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md rename to plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md index 118968d..453a248 100644 --- a/plugins/act-gitlab-ci/skills/mr-review-agent/references/copilot-code-review.md +++ b/plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md @@ -27,7 +27,7 @@ Frontmatter options: ## Installation -`/act-gitlab-ci:setup-mr-review copilot` copies the template to +`/code-reviews:setup-mr-review copilot` copies the template to `.github/instructions/code-review.instructions.md` in the target repository. Two conditions are outside the file's control and worth stating to the user: diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md b/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md similarity index 98% rename from plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md rename to plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md index b274655..008939f 100644 --- a/plugins/act-gitlab-ci/skills/mr-review-agent/references/docker-agent-config.md +++ b/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md @@ -63,7 +63,7 @@ config rather than by approval prompts: |---|---| | `shell` | An open shell plus prompt injection equals arbitrary execution | | `fetch` / remote `mcp` | Network egress is an exfiltration channel for anything in the environment | -| any write-capable tool | A reviewer that can edit is an actor, and belongs to `claude-code-ci-jobs` instead | +| any write-capable tool | A reviewer that can edit is an actor, and belongs to a Claude-Code-as-CI-actor job (the act-gitlab-ci plugin) instead | Widening the toolset is a security decision, not a convenience: everything in the job's environment (provider key included) is within reach of a prompt-injected agent with a shell. diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md b/plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md similarity index 97% rename from plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md rename to plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md index d4b63fc..d229ae0 100644 --- a/plugins/act-gitlab-ci/skills/mr-review-agent/references/gitlab-discussions-api.md +++ b/plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md @@ -53,7 +53,7 @@ behavior in tests (`scripts/tests/mr-review/`). ## Marker-based stickiness -Every posted body ends with ``, +Every posted body ends with ``, invisible in rendered markdown. `kind` is `summary` on the sticky note and `finding` on discussions and fallback notes -- the distinction is load-bearing, because GitLab lists notes newest-first and a fallback note would otherwise be mistaken for the summary and overwritten. diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md b/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md similarity index 96% rename from plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md rename to plugins/code-reviews/skills/mr-review-agent/references/review-modes.md index ece5478..42c5f83 100644 --- a/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-modes.md +++ b/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md @@ -36,7 +36,7 @@ summary so a partial review never masquerades as a full one. | `AI_REVIEW_ENGINE` | Invocation | Auth | Notes | |---|---|---|---| | `docker-agent` | `docker-agent run --exec review-agent.yaml --json --safety restricted -` | Provider key per `model:` in the config | Default. Provider-agnostic. Standalone binary, pinned by `DOCKER_AGENT_VERSION`; no Docker daemon. | -| `claude` | `claude -p --output-format json --max-turns N --allowedTools "Read Grep Glob"` | `ANTHROPIC_API_KEY`, subscription token, or the Bedrock/Vertex setups in `ci-auth-providers` | The Docker-free pipeline path. | +| `claude` | `claude -p --output-format json --max-turns N --allowedTools "Read Grep Glob"` | `ANTHROPIC_API_KEY`, subscription token, or the Bedrock/Vertex setups documented by the act-gitlab-ci plugin | The Docker-free pipeline path. | | `codex` | `codex exec --json` | OpenAI credentials | Best effort: flags move between versions; check `codex exec --help`. | | `copilot` | `copilot -p ` | GitHub Copilot auth | Best effort: same caveat. The prompt travels as one argv element, so very large diffs can exceed the OS argument limit -- lower `AI_REVIEW_MAX_DIFF_LINES` or switch to `AI_REVIEW_ENGINE_CMD` with a stdin-reading invocation. | | any | `AI_REVIEW_ENGINE_CMD` | caller's concern | Full command via `sh -c` on both surfaces (CI wrapper and local harness); prompt on stdin; must print the findings JSON. | @@ -57,7 +57,7 @@ decorative. ## Sticky and re-push semantics -Every body the wrapper posts embeds `` +Every body the wrapper posts embeds `` (`kind=summary` on the sticky note, `kind=finding` on discussions and fallback notes). - **Summary note**: found by marker and updated in place (`PUT`); created once, then stable, so diff --git a/plugins/act-gitlab-ci/skills/mr-review-agent/references/review-rubric.md b/plugins/code-reviews/skills/mr-review-agent/references/review-rubric.md similarity index 100% rename from plugins/act-gitlab-ci/skills/mr-review-agent/references/review-rubric.md rename to plugins/code-reviews/skills/mr-review-agent/references/review-rubric.md diff --git a/plugins/act-gitlab-ci/skills/review-mr/SKILL.md b/plugins/code-reviews/skills/review-mr/SKILL.md similarity index 100% rename from plugins/act-gitlab-ci/skills/review-mr/SKILL.md rename to plugins/code-reviews/skills/review-mr/SKILL.md diff --git a/plugins/act-gitlab-ci/skills/setup-mr-review/SKILL.md b/plugins/code-reviews/skills/setup-mr-review/SKILL.md similarity index 100% rename from plugins/act-gitlab-ci/skills/setup-mr-review/SKILL.md rename to plugins/code-reviews/skills/setup-mr-review/SKILL.md From b1c90e4bdcc760a8aa7b29695fef95b79f229ca5 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:33:11 -0500 Subject: [PATCH 4/5] refactor(code-reviews): rename the harness and env prefix to codereview ai-review.sh becomes codereview.sh and every AI_REVIEW_* variable becomes CODEREVIEW_*. The install directory (.gitlab/codereview/) and artifacts directory (codereview-artifacts/) follow, so the shipped names no longer mix two conventions. --- .claude-plugin/marketplace.json | 2 +- .../0005-mr-review-engine-agnostic.md | 6 +- plugins/code-reviews/README.md | 18 ++--- .../code-reviews/commands/setup-mr-review.md | 18 ++--- .../scripts/{ai-review.sh => codereview.sh} | 76 +++++++++---------- .../code-reviews/scripts/post-mr-review.ts | 62 +++++++-------- .../scripts/tests/mr-review/run-tests.sh | 50 ++++++------ .../scripts/tests/mr-review/unit.test.ts | 12 +-- .../skills/mr-review-agent/SKILL.md | 22 +++--- .../examples/git-hook-pre-push.sh | 16 ++-- .../examples/mr-review-job.yml | 16 ++-- .../examples/review-agent.yaml | 4 +- .../references/docker-agent-config.md | 2 +- .../references/review-modes.md | 18 ++--- 14 files changed, 161 insertions(+), 161 deletions(-) rename plugins/code-reviews/scripts/{ai-review.sh => codereview.sh} (65%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3434433..0285384 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -185,7 +185,7 @@ "topic": "automated code review of merge requests and pull requests", "signals": { "filesRead": [ - "**/.gitlab/ai-review/**", + "**/.gitlab/codereview/**", "**/.github/instructions/**", "**/.gitlab-ci.yml", "**/hooks/pre-push*" diff --git a/docs/decisions/0005-mr-review-engine-agnostic.md b/docs/decisions/0005-mr-review-engine-agnostic.md index 465772a..f7ca498 100644 --- a/docs/decisions/0005-mr-review-engine-agnostic.md +++ b/docs/decisions/0005-mr-review-engine-agnostic.md @@ -47,7 +47,7 @@ Ship `plugins/code-reviews/` (0.1.0), an engine-agnostic review capability: reports, the severity scale, and the findings JSON schema. Every surface derives from it, and the Copilot instructions file restates it; a rubric change is a change to all of them. -**2. Deterministic delivery.** `scripts/post-mr-review.ts` (CI) and `scripts/ai-review.sh` +**2. Deterministic delivery.** `scripts/post-mr-review.ts` (CI) and `scripts/codereview.sh` (hooks, ad-hoc) run the engine, validate its output against the contract, and deliver findings. Delivery modes: `inline` (positioned discussions plus a sticky, marker-identified summary note; the default), `summary`, and `log` — the automatic fallback when no `GITLAB_TOKEN` exists, @@ -58,7 +58,7 @@ plain notes rather than being dropped. scripts strip every GitLab token from the engine's environment; timeouts, `allow_failure: true`, `interruptible: true`, diff budgets, and turn caps bound cost. The reviewer never blocks a merge. -**4. Scripts are copied into target repositories** (`.gitlab/ai-review/`) by the +**4. Scripts are copied into target repositories** (`.gitlab/codereview/`) by the `setup-mr-review` command, because CI jobs cannot resolve `${CLAUDE_PLUGIN_ROOT}`. The canonical, tested copies stay in the plugin; re-running the command refreshes them. @@ -90,7 +90,7 @@ degrades to `log` mode rather than failing. **Two engines are tested, two are best-effort.** The fixture suite exercises docker-agent's and claude's output envelopes (saved transcripts; no engine is ever spawned in tests); codex and -copilot CLI flags are young and verified only at setup time, with `AI_REVIEW_ENGINE_CMD` as the +copilot CLI flags are young and verified only at setup time, with `CODEREVIEW_ENGINE_CMD` as the escape hatch. The suite (`scripts/tests/mr-review/`) runs with no network and no engines, so the repository gate pins parsing, positioning, the 400 fallback, mode downgrades, marker stickiness, token stripping, and re-push resolution — not model quality. diff --git a/plugins/code-reviews/README.md b/plugins/code-reviews/README.md index 5672e76..79b5a26 100644 --- a/plugins/code-reviews/README.md +++ b/plugins/code-reviews/README.md @@ -39,13 +39,13 @@ One review rubric and one findings contract, delivered on four surfaces: | Surface | How | Setup | |---|---|---| | GitLab CI, every MR | A job runs an AI engine over the diff and posts the review | `/code-reviews:setup-mr-review gitlab-ci` | -| Git hook / scripts | `ai-review.sh` reviews a local diff with whatever engine is on PATH | `/code-reviews:setup-mr-review git-hook` | +| Git hook / scripts | `codereview.sh` reviews a local diff with whatever engine is on PATH | `/code-reviews:setup-mr-review git-hook` | | In-session | `/code-reviews:review-mr` on any host the plugin is installed in | none | | GitHub Copilot native review | A `.github/instructions/` file carries the rubric | `/code-reviews:setup-mr-review copilot` | -Engines are swappable (`AI_REVIEW_ENGINE`): `docker-agent` (default, provider-agnostic, a pinned +Engines are swappable (`CODEREVIEW_ENGINE`): `docker-agent` (default, provider-agnostic, a pinned standalone binary -- no Docker daemon), `claude`, `codex`, `copilot`, or any command via -`AI_REVIEW_ENGINE_CMD`. The engine only ever produces findings; deterministic script code does +`CODEREVIEW_ENGINE_CMD`. The engine only ever produces findings; deterministic script code does all posting. ## What ships @@ -54,7 +54,7 @@ all posting. |---|---|---| | Skills | 3 | The review knowledge skill plus portable adapters for both commands | | Commands | 2 | Install a review surface; review one MR in-session | -| Scripts | 2 | The CI wrapper (`post-mr-review.ts`) and the engine-dispatch harness (`ai-review.sh`), with a fixture-driven test suite | +| Scripts | 2 | The CI wrapper (`post-mr-review.ts`) and the engine-dispatch harness (`codereview.sh`), with a fixture-driven test suite | | Templates | 4 | CI job, docker-agent config, Copilot instructions file, pre-push hook | ## Skills @@ -87,7 +87,7 @@ claude plugin install code-reviews@actdata-plugins summary so a partial review never poses as a full one. 2. The selected engine reviews the diff against the rubric and emits the findings contract: `{summary, findings: [{path, new_line, old_line, severity, title, body}]}`. -3. Delivery per `AI_REVIEW_MODE`: +3. Delivery per `CODEREVIEW_MODE`: | Mode | Requires | Result | |---|---|---| @@ -96,7 +96,7 @@ claude plugin install code-reviews@actdata-plugins | `log` | nothing | Job log plus artifacts. Automatic fallback when `GITLAB_TOKEN` is unset. | The reviewer never blocks a merge: the job runs `allow_failure: true`, and the pre-push hook is -advisory unless `AI_REVIEW_BLOCKING=1`. +advisory unless `CODEREVIEW_BLOCKING=1`. ## Configuration @@ -106,7 +106,7 @@ CI/CD variables, all masked, created by the user (the plugin never handles value |---|---|---| | Provider API key (e.g. `ANTHROPIC_API_KEY`) | yes | Whatever key the chosen engine's `model:` needs | | `GITLAB_TOKEN` | for `inline`/`summary` | Project access token, `api` scope, Developer role. `CI_JOB_TOKEN` cannot create MR notes. | -| `AI_REVIEW_MODE`, `AI_REVIEW_ENGINE` | no | Defaults: `inline`, `docker-agent` | +| `CODEREVIEW_MODE`, `CODEREVIEW_ENGINE` | no | Defaults: `inline`, `docker-agent` | | `DOCKER_AGENT_VERSION`, `DOCKER_AGENT_SHA256` | no | Pinned binary release and optional checksum | ## Security model @@ -121,7 +121,7 @@ CI/CD variables, all masked, created by the user (the plugin never handles value ## What this plugin does NOT do > [!CAUTION] -> `setup-mr-review` writes to your repository (`.gitlab-ci.yml`, `.gitlab/ai-review/`, +> `setup-mr-review` writes to your repository (`.gitlab-ci.yml`, `.gitlab/codereview/`, > `.github/instructions/`, hooks). `review-mr` posts to an MR only on explicit confirmation. - **No credential handling.** Variables are set in the GitLab or GitHub UI; the plugin tells the @@ -143,7 +143,7 @@ code-reviews/ commands/ setup-mr-review.md review-mr.md scripts/ - post-mr-review.ts ai-review.sh + post-mr-review.ts codereview.sh tests/mr-review/ run-tests.sh unit.test.ts fixtures/ skills/ mr-review-agent/ SKILL.md + references/(5) + examples/(4) diff --git a/plugins/code-reviews/commands/setup-mr-review.md b/plugins/code-reviews/commands/setup-mr-review.md index b1a1a2d..274fa80 100644 --- a/plugins/code-reviews/commands/setup-mr-review.md +++ b/plugins/code-reviews/commands/setup-mr-review.md @@ -21,9 +21,9 @@ From the first word of `$ARGUMENTS`, or ask: ## gitlab-ci -1. Copy from the plugin into the repository at `.gitlab/ai-review/`: +1. Copy from the plugin into the repository at `.gitlab/codereview/`: - `${CLAUDE_PLUGIN_ROOT}/scripts/post-mr-review.ts` - - `${CLAUDE_PLUGIN_ROOT}/scripts/ai-review.sh` + - `${CLAUDE_PLUGIN_ROOT}/scripts/codereview.sh` - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/references/review-rubric.md` - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/review-agent.yaml` @@ -33,12 +33,12 @@ From the first word of `$ARGUMENTS`, or ask: 2. Read the repository's `.gitlab-ci.yml` if there is one and match its stage names and conventions. Then add the job from `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/mr-review-job.yml`, adapting: - - `AI_REVIEW_MODE` from the second argument (default `inline`). - - `AI_REVIEW_ENGINE` from the third argument (default `docker-agent`). + - `CODEREVIEW_MODE` from the second argument (default `inline`). + - `CODEREVIEW_ENGINE` from the third argument (default `docker-agent`). - Keep `timeout`, `allow_failure: true`, `interruptible: true`, and the draft-skip rules; they are cost and safety bounds, not decoration. -3. If the engine is `docker-agent`, set `model:` in `.gitlab/ai-review/review-agent.yaml` to the +3. If the engine is `docker-agent`, set `model:` in `.gitlab/codereview/review-agent.yaml` to the user's provider and model. Ask rather than guess the provider. 4. Tell the user which CI/CD variables to create under Settings, CI/CD, Variables. Never handle @@ -53,16 +53,16 @@ From the first word of `$ARGUMENTS`, or ask: ## git-hook -1. Copy `post-mr-review.ts`, `ai-review.sh`, `review-rubric.md`, and `review-agent.yaml` to - `.gitlab/ai-review/` as above (the harness and hook resolve them there). +1. Copy `post-mr-review.ts`, `codereview.sh`, `review-rubric.md`, and `review-agent.yaml` to + `.gitlab/codereview/` as above (the harness and hook resolve them there). 2. Install `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/git-hook-pre-push.sh`: - If the repository uses a hook manager (lefthook, husky, `core.hooksPath`), add it there and say where. - Otherwise copy it to `.git/hooks/pre-push` and mark it executable. Note that `.git/hooks` is per-clone and not versioned, so each contributor installs it themselves. -3. State the defaults: advisory (findings print, the push proceeds), `AI_REVIEW_BLOCKING=1` gates, +3. State the defaults: advisory (findings print, the push proceeds), `CODEREVIEW_BLOCKING=1` gates, `git push --no-verify` bypasses, and the engine is auto-detected from PATH unless - `AI_REVIEW_ENGINE` is set. + `CODEREVIEW_ENGINE` is set. ## copilot diff --git a/plugins/code-reviews/scripts/ai-review.sh b/plugins/code-reviews/scripts/codereview.sh similarity index 65% rename from plugins/code-reviews/scripts/ai-review.sh rename to plugins/code-reviews/scripts/codereview.sh index d033a2e..d57f0fd 100644 --- a/plugins/code-reviews/scripts/ai-review.sh +++ b/plugins/code-reviews/scripts/codereview.sh @@ -1,6 +1,6 @@ #!/bin/sh # ============================================================================= -# ai-review -- run the review rubric over a local diff with whatever AI engine +# codereview -- run the review rubric over a local diff with whatever AI engine # is installed, and print the findings. # # The engine-agnostic harness for scripted surfaces: git hooks, ad-hoc runs, @@ -8,38 +8,38 @@ # never talks to GitLab; it reviews a local diff and reports to stdout. # # Usage: -# ai-review.sh [git diff arguments] # default: git diff HEAD -# ai-review.sh origin/main...HEAD # a pre-push style range +# codereview.sh [git diff arguments] # default: git diff HEAD +# codereview.sh origin/main...HEAD # a pre-push style range # # Environment: -# AI_REVIEW_ENGINE docker-agent | claude | codex | copilot -# (default: first of those found on PATH) -# AI_REVIEW_ENGINE_CMD full command run via sh -c, prompt on stdin; -# overrides AI_REVIEW_ENGINE entirely -# AI_REVIEW_ENGINE_FLAGS safety flags for docker-agent (default: -# --safety restricted) -# AI_REVIEW_AGENT_CONFIG docker-agent config path (default: -# review-agent.yaml next to this script) -# AI_REVIEW_RUBRIC rubric path (default: review-rubric.md next to -# this script, then the plugin skill copy) -# AI_REVIEW_MAX_TURNS turn cap for CLI engines (default 25) -# AI_REVIEW_DIFF_FILE read the diff from a file instead of running git -# AI_REVIEW_BLOCKING "1" exits 1 when a blocker is found (for hooks -# that gate; default advisory) +# CODEREVIEW_ENGINE docker-agent | claude | codex | copilot +# (default: first of those found on PATH) +# CODEREVIEW_ENGINE_CMD full command run via sh -c, prompt on stdin; +# overrides CODEREVIEW_ENGINE entirely +# CODEREVIEW_ENGINE_FLAGS safety flags for docker-agent (default: +# --safety restricted) +# CODEREVIEW_AGENT_CONFIG docker-agent config path (default: +# review-agent.yaml next to this script) +# CODEREVIEW_RUBRIC rubric path (default: review-rubric.md next to +# this script, then the plugin skill copy) +# CODEREVIEW_MAX_TURNS turn cap for CLI engines (default 25) +# CODEREVIEW_DIFF_FILE read the diff from a file instead of running git +# CODEREVIEW_BLOCKING "1" exits 1 when a blocker is found (for hooks +# that gate; default advisory) # # Exit: 0 review printed (or empty diff), 1 engine/contract failure or a -# blocker under AI_REVIEW_BLOCKING=1, 2 configuration error. +# blocker under CODEREVIEW_BLOCKING=1, 2 configuration error. # ============================================================================= set -u SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -err() { printf 'ai-review: %s\n' "$1" >&2; } +err() { printf 'codereview: %s\n' "$1" >&2; } # --- locate the rubric ------------------------------------------------------- -RUBRIC="${AI_REVIEW_RUBRIC:-}" +RUBRIC="${CODEREVIEW_RUBRIC:-}" if [ -z "$RUBRIC" ]; then for candidate in \ "$SCRIPT_DIR/review-rubric.md" \ @@ -48,19 +48,19 @@ if [ -z "$RUBRIC" ]; then done fi if [ -z "$RUBRIC" ] || [ ! -f "$RUBRIC" ]; then - err "rubric not found; set AI_REVIEW_RUBRIC" + err "rubric not found; set CODEREVIEW_RUBRIC" exit 2 fi # --- collect the diff -------------------------------------------------------- -WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/ai-review.XXXXXX") || exit 2 +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/codereview.XXXXXX") || exit 2 cleanup() { rm -rf "$WORKDIR"; } trap cleanup EXIT DIFF_FILE="$WORKDIR/diff.patch" -if [ -n "${AI_REVIEW_DIFF_FILE:-}" ]; then - cp "$AI_REVIEW_DIFF_FILE" "$DIFF_FILE" || exit 2 +if [ -n "${CODEREVIEW_DIFF_FILE:-}" ]; then + cp "$CODEREVIEW_DIFF_FILE" "$DIFF_FILE" || exit 2 elif [ $# -gt 0 ]; then git diff --no-color "$@" >"$DIFF_FILE" || { err "git diff failed"; exit 2; } else @@ -68,7 +68,7 @@ else fi if [ ! -s "$DIFF_FILE" ]; then - echo "ai-review: empty diff, nothing to review." + echo "codereview: empty diff, nothing to review." exit 0 fi @@ -88,37 +88,37 @@ PROMPT_FILE="$WORKDIR/prompt.md" # in its environment. OUT_FILE="$WORKDIR/engine-output.txt" -MAX_TURNS="${AI_REVIEW_MAX_TURNS:-25}" +MAX_TURNS="${CODEREVIEW_MAX_TURNS:-25}" # Set by run_engine on OUR configuration errors, so an engine that happens to # exit 2 is not mistaken for one. CONFIG_ERR=0 run_engine() { - if [ -n "${AI_REVIEW_ENGINE_CMD:-}" ]; then - GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= sh -c "$AI_REVIEW_ENGINE_CMD" <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" + if [ -n "${CODEREVIEW_ENGINE_CMD:-}" ]; then + GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= sh -c "$CODEREVIEW_ENGINE_CMD" <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" return $? fi - engine="${AI_REVIEW_ENGINE:-}" + engine="${CODEREVIEW_ENGINE:-}" if [ -z "$engine" ]; then for candidate in docker-agent claude codex copilot; do if command -v "$candidate" >/dev/null 2>&1; then engine="$candidate"; break; fi done fi if [ -z "$engine" ]; then - err "no engine found (docker-agent, claude, codex or copilot); set AI_REVIEW_ENGINE_CMD" + err "no engine found (docker-agent, claude, codex or copilot); set CODEREVIEW_ENGINE_CMD" CONFIG_ERR=1 return 2 fi case "$engine" in docker-agent) - config="${AI_REVIEW_AGENT_CONFIG:-$SCRIPT_DIR/review-agent.yaml}" + config="${CODEREVIEW_AGENT_CONFIG:-$SCRIPT_DIR/review-agent.yaml}" [ -f "$config" ] || config="$SCRIPT_DIR/../skills/mr-review-agent/examples/review-agent.yaml" # shellcheck disable=SC2086 -- flags are deliberately word-split GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= TELEMETRY_ENABLED=false \ - docker-agent run --exec "$config" --json ${AI_REVIEW_ENGINE_FLAGS:---safety restricted} - \ + docker-agent run --exec "$config" --json ${CODEREVIEW_ENGINE_FLAGS:---safety restricted} - \ <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" ;; claude) @@ -128,16 +128,16 @@ run_engine() { ;; codex) # Best effort: verify flags against `codex exec --help` for the - # installed version; override with AI_REVIEW_ENGINE_CMD on drift. + # installed version; override with CODEREVIEW_ENGINE_CMD on drift. GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ codex exec --json <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" ;; copilot) # Best effort: verify flags against `copilot --help` for the installed - # version; override with AI_REVIEW_ENGINE_CMD on drift. The prompt rides + # version; override with CODEREVIEW_ENGINE_CMD on drift. The prompt rides # argv (no stdin mode), which the OS caps at 128 KiB per element. if [ "$(wc -c <"$PROMPT_FILE")" -gt 120000 ]; then - err "prompt too large for the copilot engine's argument passing; lower the diff size or use AI_REVIEW_ENGINE_CMD with a stdin-reading command" + err "prompt too large for the copilot engine's argument passing; lower the diff size or use CODEREVIEW_ENGINE_CMD with a stdin-reading command" CONFIG_ERR=1 return 2 fi @@ -145,7 +145,7 @@ run_engine() { copilot -p "$(cat "$PROMPT_FILE")" "$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" ;; *) - err "unknown AI_REVIEW_ENGINE \"$engine\"" + err "unknown CODEREVIEW_ENGINE \"$engine\"" CONFIG_ERR=1 return 2 ;; @@ -169,7 +169,7 @@ fi EXTRACTOR="$SCRIPT_DIR/post-mr-review.ts" if command -v bun >/dev/null 2>&1 && [ -f "$EXTRACTOR" ]; then - if [ "${AI_REVIEW_BLOCKING:-0}" = "1" ]; then + if [ "${CODEREVIEW_BLOCKING:-0}" = "1" ]; then bun "$EXTRACTOR" --extract "$OUT_FILE" --report --blocking else bun "$EXTRACTOR" --extract "$OUT_FILE" --report @@ -178,7 +178,7 @@ if command -v bun >/dev/null 2>&1 && [ -f "$EXTRACTOR" ]; then fi cat "$OUT_FILE" -if [ "${AI_REVIEW_BLOCKING:-0}" = "1" ] && +if [ "${CODEREVIEW_BLOCKING:-0}" = "1" ] && grep -Eq '"severity"[[:space:]]*:[[:space:]]*"blocker"' "$OUT_FILE"; then err "blocker finding present" exit 1 diff --git a/plugins/code-reviews/scripts/post-mr-review.ts b/plugins/code-reviews/scripts/post-mr-review.ts index de52ce3..5e3b50d 100644 --- a/plugins/code-reviews/scripts/post-mr-review.ts +++ b/plugins/code-reviews/scripts/post-mr-review.ts @@ -11,15 +11,15 @@ * prompt, spawns the engine, validates the output against the findings * contract (see review-rubric.md), and posts the review. * - * Modes (AI_REVIEW_MODE, default "inline"): + * Modes (CODEREVIEW_MODE, default "inline"): * inline one positioned discussion per finding + a sticky summary note; * findings whose position GitLab rejects degrade to plain notes * summary a single sticky summary note * log job log + artifacts only; the automatic fallback when no * GITLAB_TOKEN is set, because CI_JOB_TOKEN cannot create MR notes * - * Engines (AI_REVIEW_ENGINE, default "docker-agent"): - * docker-agent | claude | codex | copilot | custom via AI_REVIEW_ENGINE_CMD + * Engines (CODEREVIEW_ENGINE, default "docker-agent"): + * docker-agent | claude | codex | copilot | custom via CODEREVIEW_ENGINE_CMD * * The engine subprocess never receives GITLAB_TOKEN: merge-request code is * untrusted input to the model, and a prompt-injected engine must have nothing @@ -187,7 +187,7 @@ export function resolveMode(requested: string | undefined, hasToken: boolean): { const wanted = (requested || "inline").toLowerCase(); if (wanted !== "inline" && wanted !== "summary" && wanted !== "log") { // A typo must not silently escalate to the most-privileged posting mode. - throw new Error(`unknown AI_REVIEW_MODE "${requested}" (inline|summary|log)`); + throw new Error(`unknown CODEREVIEW_MODE "${requested}" (inline|summary|log)`); } const mode = wanted as Mode; if (mode !== "log" && !hasToken) return { mode: "log", downgraded: true }; @@ -203,18 +203,18 @@ export interface EngineSpec { * The engine allowlist. docker-agent and claude are the tested pair; codex * and copilot are best-effort (their headless flags move fast -- verify with * `codex exec --help` / `copilot --help` and override with - * AI_REVIEW_ENGINE_CMD when they drift). + * CODEREVIEW_ENGINE_CMD when they drift). */ export function engineSpec(engine: string, env: Record): EngineSpec { - const custom = env.AI_REVIEW_ENGINE_CMD; + const custom = env.CODEREVIEW_ENGINE_CMD; if (custom && custom.trim().length > 0) { - // Through a shell, same as ai-review.sh, so pipes and quoting behave + // Through a shell, same as codereview.sh, so pipes and quoting behave // identically on both surfaces. return { argv: ["sh", "-c", custom.trim()], promptVia: "stdin" }; } - const maxTurns = env.AI_REVIEW_MAX_TURNS || "25"; - const config = env.AI_REVIEW_AGENT_CONFIG || ".gitlab/ai-review/review-agent.yaml"; - const safety = env.AI_REVIEW_ENGINE_FLAGS || "--safety restricted"; + const maxTurns = env.CODEREVIEW_MAX_TURNS || "25"; + const config = env.CODEREVIEW_AGENT_CONFIG || ".gitlab/codereview/review-agent.yaml"; + const safety = env.CODEREVIEW_ENGINE_FLAGS || "--safety restricted"; switch (engine) { case "claude": return { @@ -231,7 +231,7 @@ export function engineSpec(engine: string, env: Record MAX_ARG_PROMPT_BYTES) { fail( `the prompt (${Buffer.byteLength(prompt, "utf8")} bytes) exceeds the OS argument limit for this ` + - `engine; lower AI_REVIEW_MAX_DIFF_LINES or use AI_REVIEW_ENGINE_CMD with a stdin-reading command`, + `engine; lower CODEREVIEW_MAX_DIFF_LINES or use CODEREVIEW_ENGINE_CMD with a stdin-reading command`, 2, ); } @@ -619,19 +619,19 @@ export function parseGitDiff(raw: string): FileDiff[] { async function main(): Promise { const env = process.env; - const dryRun = env.AI_REVIEW_DRY_RUN === "1"; - const artifactsDir = env.AI_REVIEW_ARTIFACTS || "ai-review-artifacts"; + const dryRun = env.CODEREVIEW_DRY_RUN === "1"; + const artifactsDir = env.CODEREVIEW_ARTIFACTS || "codereview-artifacts"; const hasToken = Boolean(env.GITLAB_TOKEN); let resolved: { mode: Mode; downgraded: boolean }; try { - resolved = resolveMode(env.AI_REVIEW_MODE, hasToken); + resolved = resolveMode(env.CODEREVIEW_MODE, hasToken); } catch (err) { fail(err instanceof Error ? err.message : String(err), 2); } const { mode, downgraded } = resolved; if (downgraded) { console.error( - `post-mr-review: AI_REVIEW_MODE=${env.AI_REVIEW_MODE || "inline"} requires GITLAB_TOKEN ` + + `post-mr-review: CODEREVIEW_MODE=${env.CODEREVIEW_MODE || "inline"} requires GITLAB_TOKEN ` + `(CI_JOB_TOKEN cannot create MR notes); falling back to log mode.`, ); } @@ -656,8 +656,8 @@ async function main(): Promise { let existingDiscussions: ExistingDiscussion[] = []; if (mode !== "log") { - const mrRaw = dryRun && env.AI_REVIEW_FIXTURE_CHANGES - ? readJsonFile(env.AI_REVIEW_FIXTURE_CHANGES) + const mrRaw = dryRun && env.CODEREVIEW_FIXTURE_CHANGES + ? readJsonFile(env.CODEREVIEW_FIXTURE_CHANGES) : (await http("GET", "/changes")).body; const mr = (mrRaw || {}) as { title?: string; @@ -676,13 +676,13 @@ async function main(): Promise { diffRefs = mr.diff_refs || null; files = mr.changes || []; - const notesRaw = dryRun && env.AI_REVIEW_FIXTURE_NOTES - ? readJsonFile(env.AI_REVIEW_FIXTURE_NOTES) + const notesRaw = dryRun && env.CODEREVIEW_FIXTURE_NOTES + ? readJsonFile(env.CODEREVIEW_FIXTURE_NOTES) : (await http("GET", "/notes?per_page=100")).body; existingNotes = ((notesRaw as { id: number; body: string }[]) || []).map((n) => ({ id: n.id, body: n.body })); - const discussionsRaw = dryRun && env.AI_REVIEW_FIXTURE_DISCUSSIONS - ? readJsonFile(env.AI_REVIEW_FIXTURE_DISCUSSIONS) + const discussionsRaw = dryRun && env.CODEREVIEW_FIXTURE_DISCUSSIONS + ? readJsonFile(env.CODEREVIEW_FIXTURE_DISCUSSIONS) : (await http("GET", "/discussions?per_page=100")).body; existingDiscussions = ((discussionsRaw as { id: string; notes?: { body: string; resolved?: boolean }[] }[]) || []) .filter((d) => (d.notes || []).length > 0) @@ -704,30 +704,30 @@ async function main(): Promise { if (baseSha) files = localDiff(baseSha); } - const maxFileLines = Number(env.AI_REVIEW_MAX_FILE_LINES || 1500); - const maxTotalLines = Number(env.AI_REVIEW_MAX_DIFF_LINES || 6000); + const maxFileLines = Number(env.CODEREVIEW_MAX_FILE_LINES || 1500); + const maxTotalLines = Number(env.CODEREVIEW_MAX_DIFF_LINES || 6000); const { text: diffText, truncated } = truncateDiff(files, maxFileLines, maxTotalLines); - if (diffText.trim().length === 0 && !env.AI_REVIEW_FIXTURE_OUTPUT) { + if (diffText.trim().length === 0 && !env.CODEREVIEW_FIXTURE_OUTPUT) { console.log("post-mr-review: empty diff, nothing to review."); return; } - const rubricPath = env.AI_REVIEW_RUBRIC || ".gitlab/ai-review/review-rubric.md"; + const rubricPath = env.CODEREVIEW_RUBRIC || ".gitlab/codereview/review-rubric.md"; let rubric = ""; try { rubric = readFileSync(rubricPath, "utf8"); } catch { // A saved engine output makes the prompt (and so the rubric) unused. - if (!env.AI_REVIEW_FIXTURE_OUTPUT) fail(`rubric not found at ${rubricPath}`, 2); + if (!env.CODEREVIEW_FIXTURE_OUTPUT) fail(`rubric not found at ${rubricPath}`, 2); } const prompt = buildPrompt(rubric, { title: mrTitle, description: mrDescription }, diffText, truncated); mkdirSync(artifactsDir, { recursive: true }); let rawOutput: string; - if (env.AI_REVIEW_FIXTURE_OUTPUT) { - rawOutput = readFileSync(env.AI_REVIEW_FIXTURE_OUTPUT, "utf8"); + if (env.CODEREVIEW_FIXTURE_OUTPUT) { + rawOutput = readFileSync(env.CODEREVIEW_FIXTURE_OUTPUT, "utf8"); } else { - const engine = env.AI_REVIEW_ENGINE || "docker-agent"; + const engine = env.CODEREVIEW_ENGINE || "docker-agent"; let spec: EngineSpec; try { spec = engineSpec(engine, env); @@ -768,7 +768,7 @@ async function main(): Promise { } // --------------------------------------------------------------------------- -// --extract: parse a saved engine output file (used by ai-review.sh) +// --extract: parse a saved engine output file (used by codereview.sh) // --------------------------------------------------------------------------- function extractCli(args: string[]): void { diff --git a/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh b/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh index 0b6834a..8dfa24a 100644 --- a/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh +++ b/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh @@ -1,6 +1,6 @@ #!/bin/sh # ============================================================================= -# Tests for post-mr-review.ts and ai-review.sh. +# Tests for post-mr-review.ts and codereview.sh. # # Fixture-driven throughout: no network, no GitLab, no real engines. The # wrapper's pure functions are covered by unit.test.ts under `bun test`; this @@ -14,7 +14,7 @@ set -u SUITE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) PLUGIN_DIR=$(CDPATH= cd -- "$SUITE_DIR/../../.." && pwd) WRAPPER="$PLUGIN_DIR/scripts/post-mr-review.ts" -HARNESS="$PLUGIN_DIR/scripts/ai-review.sh" +HARNESS="$PLUGIN_DIR/scripts/codereview.sh" FIXTURES="$SUITE_DIR/fixtures" passed=0 @@ -23,7 +23,7 @@ failed=0 pass() { passed=$((passed + 1)); printf ' ok %s\n' "$1"; } fail() { failed=$((failed + 1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; } -echo "post-mr-review.ts / ai-review.sh" +echo "post-mr-review.ts / codereview.sh" for f in "$WRAPPER" "$HARNESS"; do if [ ! -f "$f" ]; then @@ -94,18 +94,18 @@ fi run_dry() { # $1 head sha, $2 mode, $3 token ("" for none), remaining env via caller - AI_REVIEW_DRY_RUN=1 \ + CODEREVIEW_DRY_RUN=1 \ GITLAB_TOKEN="$3" \ - AI_REVIEW_MODE="$2" \ + CODEREVIEW_MODE="$2" \ CI_API_V4_URL="https://gitlab.example.com/api/v4" \ CI_PROJECT_ID="123" \ CI_MERGE_REQUEST_IID="7" \ CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="$1" \ - AI_REVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes.json" \ - AI_REVIEW_FIXTURE_NOTES="$FIXTURES/notes-with-marker.json" \ - AI_REVIEW_FIXTURE_DISCUSSIONS="$FIXTURES/discussions-stale.json" \ - AI_REVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ - AI_REVIEW_ARTIFACTS="$WORK/artifacts" \ + CODEREVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes.json" \ + CODEREVIEW_FIXTURE_NOTES="$FIXTURES/notes-with-marker.json" \ + CODEREVIEW_FIXTURE_DISCUSSIONS="$FIXTURES/discussions-stale.json" \ + CODEREVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ + CODEREVIEW_ARTIFACTS="$WORK/artifacts" \ bun "$WRAPPER" 2>"$WORK/dry-stderr.txt" } @@ -153,12 +153,12 @@ fi # --- dry-run: draft MRs are skipped server-side --------------------------------- -out=$(AI_REVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token AI_REVIEW_MODE=inline \ +out=$(CODEREVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token CODEREVIEW_MODE=inline \ CI_API_V4_URL="https://gitlab.example.com/api/v4" CI_PROJECT_ID="123" CI_MERGE_REQUEST_IID="8" \ CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="$HEAD_SHA" \ - AI_REVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes-draft.json" \ - AI_REVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ - AI_REVIEW_ARTIFACTS="$WORK/artifacts-draft" \ + CODEREVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes-draft.json" \ + CODEREVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ + CODEREVIEW_ARTIFACTS="$WORK/artifacts-draft" \ bun "$WRAPPER" 2>&1) if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'draft merge request' && ! printf '%s' "$out" | grep -q '"planned"'; then @@ -169,13 +169,13 @@ fi # --- configuration typos exit 2, not 1 ------------------------------------------ -AI_REVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token AI_REVIEW_MODE=inlien \ +CODEREVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token CODEREVIEW_MODE=inlien \ CI_MERGE_REQUEST_IID="7" bun "$WRAPPER" >/dev/null 2>&1 status=$? if [ "$status" -eq 2 ]; then - pass "unknown AI_REVIEW_MODE exits 2" + pass "unknown CODEREVIEW_MODE exits 2" else - fail "unknown AI_REVIEW_MODE exits 2" "exit $status" + fail "unknown CODEREVIEW_MODE exits 2" "exit $status" fi # --- dry-run: pipeline retry is a no-op ---------------------------------------- @@ -205,8 +205,8 @@ printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts out=$(TMPDIR="$WORK" \ GITLAB_TOKEN=super-secret GITLAB_ACCESS_TOKEN=also-secret CI_JOB_TOKEN=job-secret \ PROBE_CONTROL=present \ - AI_REVIEW_DIFF_FILE="$WORK/probe.diff" \ - AI_REVIEW_ENGINE_CMD="sh $FIXTURES/stub-env-probe.sh" \ + CODEREVIEW_DIFF_FILE="$WORK/probe.diff" \ + CODEREVIEW_ENGINE_CMD="sh $FIXTURES/stub-env-probe.sh" \ sh "$HARNESS" 2>&1) if printf '%s' "$out" | grep -q 'env probe: clean control-ok'; then pass "harness strips GitLab tokens but not the rest of the env" @@ -219,8 +219,8 @@ fi printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n+changed\n' >"$WORK/stub.diff" out=$(TMPDIR="$WORK" \ - AI_REVIEW_DIFF_FILE="$WORK/stub.diff" \ - AI_REVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ + CODEREVIEW_DIFF_FILE="$WORK/stub.diff" \ + CODEREVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ sh "$HARNESS" 2>&1) if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Stub blocker finding'; then pass "harness reports stub engine findings" @@ -229,9 +229,9 @@ else fi if TMPDIR="$WORK" \ - AI_REVIEW_DIFF_FILE="$WORK/stub.diff" \ - AI_REVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ - AI_REVIEW_BLOCKING=1 \ + CODEREVIEW_DIFF_FILE="$WORK/stub.diff" \ + CODEREVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ + CODEREVIEW_BLOCKING=1 \ sh "$HARNESS" >/dev/null 2>&1; then fail "harness blocking mode exits 1 on a blocker" else @@ -239,7 +239,7 @@ else fi : >"$WORK/empty.diff" -out=$(TMPDIR="$WORK" AI_REVIEW_DIFF_FILE="$WORK/empty.diff" sh "$HARNESS" 2>&1) +out=$(TMPDIR="$WORK" CODEREVIEW_DIFF_FILE="$WORK/empty.diff" sh "$HARNESS" 2>&1) if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'empty diff'; then pass "harness exits 0 on an empty diff" else diff --git a/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts b/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts index 77745ef..8e58688 100644 --- a/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts +++ b/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts @@ -105,14 +105,14 @@ describe("resolveMode", () => { expect(resolveMode(undefined, true).mode).toBe("inline"); }); test("rejects unknown modes instead of escalating to inline", () => { - expect(() => resolveMode("summry", true)).toThrow("unknown AI_REVIEW_MODE"); + expect(() => resolveMode("summry", true)).toThrow("unknown CODEREVIEW_MODE"); }); }); describe("engineSpec", () => { test("each named engine produces its documented argv shape", () => { expect(engineSpec("docker-agent", {}).argv).toEqual([ - "docker-agent", "run", "--exec", ".gitlab/ai-review/review-agent.yaml", "--json", "--safety", "restricted", "-", + "docker-agent", "run", "--exec", ".gitlab/codereview/review-agent.yaml", "--json", "--safety", "restricted", "-", ]); expect(engineSpec("claude", {}).argv).toEqual([ "claude", "-p", "--output-format", "json", "--max-turns", "25", "--allowedTools", "Read Grep Glob", @@ -121,18 +121,18 @@ describe("engineSpec", () => { expect(engineSpec("copilot", {})).toEqual({ argv: ["copilot", "-p"], promptVia: "arg" }); }); - test("AI_REVIEW_ENGINE_CMD runs through a shell, matching ai-review.sh", () => { - const spec = engineSpec("docker-agent", { AI_REVIEW_ENGINE_CMD: "my-engine --flag 'quoted arg'" }); + test("CODEREVIEW_ENGINE_CMD runs through a shell, matching codereview.sh", () => { + const spec = engineSpec("docker-agent", { CODEREVIEW_ENGINE_CMD: "my-engine --flag 'quoted arg'" }); expect(spec.argv).toEqual(["sh", "-c", "my-engine --flag 'quoted arg'"]); expect(spec.promptVia).toBe("stdin"); }); test("a whitespace-only override falls back to the named engine", () => { - expect(engineSpec("codex", { AI_REVIEW_ENGINE_CMD: " " }).argv[0]).toBe("codex"); + expect(engineSpec("codex", { CODEREVIEW_ENGINE_CMD: " " }).argv[0]).toBe("codex"); }); test("an unknown engine throws instead of guessing", () => { - expect(() => engineSpec("gpt", {})).toThrow("unknown AI_REVIEW_ENGINE"); + expect(() => engineSpec("gpt", {})).toThrow("unknown CODEREVIEW_ENGINE"); }); }); diff --git a/plugins/code-reviews/skills/mr-review-agent/SKILL.md b/plugins/code-reviews/skills/mr-review-agent/SKILL.md index 87b1fde..a4cf78a 100644 --- a/plugins/code-reviews/skills/mr-review-agent/SKILL.md +++ b/plugins/code-reviews/skills/mr-review-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: mr-review-agent -description: This skill should be used when the user asks to "review merge requests automatically", "set up AI code review", "run a review agent in the pipeline", "post review comments on MRs", "review every MR", or mentions docker-agent, AI_REVIEW_MODE, AI_REVIEW_ENGINE, GITLAB_TOKEN for review comments, post-mr-review.ts, ai-review.sh, review-agent.yaml, review-rubric.md, a pre-push AI review hook, inline MR discussions from CI, or Copilot code review instructions. Covers the review contract, the engine matrix, delivery modes, tokens, and re-push semantics. +description: This skill should be used when the user asks to "review merge requests automatically", "set up AI code review", "run a review agent in the pipeline", "post review comments on MRs", "review every MR", or mentions docker-agent, CODEREVIEW_MODE, CODEREVIEW_ENGINE, GITLAB_TOKEN for review comments, post-mr-review.ts, codereview.sh, review-agent.yaml, review-rubric.md, a pre-push AI review hook, inline MR discussions from CI, or Copilot code review instructions. Covers the review contract, the engine matrix, delivery modes, tokens, and re-push semantics. --- # Automated merge-request review @@ -16,10 +16,10 @@ the scripts: - **`references/review-rubric.md`** -- what a reviewer looks for, the severity scale, and the findings JSON contract `{summary, findings: [{path, new_line, old_line, severity, title, body}]}`. - **Deterministic delivery scripts** -- `scripts/post-mr-review.ts` (CI, posts to GitLab) and - `scripts/ai-review.sh` (local, prints a report). The engine only ever produces findings; the + `scripts/codereview.sh` (local, prints a report). The engine only ever produces findings; the scripts do everything with side effects. -Engines are swappable via `AI_REVIEW_ENGINE`: +Engines are swappable via `CODEREVIEW_ENGINE`: | Engine | Runs | Status | |---|---|---| @@ -28,7 +28,7 @@ Engines are swappable via `AI_REVIEW_ENGINE`: | `codex` | `codex exec` | Best effort; verify flags per version | | `copilot` | Copilot CLI | Best effort; verify flags per version | -Any other engine: set `AI_REVIEW_ENGINE_CMD` to a command that reads the prompt on stdin and +Any other engine: set `CODEREVIEW_ENGINE_CMD` to a command that reads the prompt on stdin and prints the findings JSON. ## Four surfaces @@ -36,22 +36,22 @@ prints the findings JSON. | Surface | Entry point | Setup | |---|---|---| | GitLab CI on every MR | `examples/mr-review-job.yml` running `post-mr-review.ts` | `/code-reviews:setup-mr-review gitlab-ci` | -| Git hooks / scripts | `scripts/ai-review.sh`, `examples/git-hook-pre-push.sh` | `/code-reviews:setup-mr-review git-hook` | +| Git hooks / scripts | `scripts/codereview.sh`, `examples/git-hook-pre-push.sh` | `/code-reviews:setup-mr-review git-hook` | | In-session, any host | the `review-mr` command | installed with the plugin | | GitHub Copilot native review | `examples/copilot-code-review.instructions.md` | `/code-reviews:setup-mr-review copilot` | -The CI scripts are **copied into the target repository** (conventionally `.gitlab/ai-review/`) +The CI scripts are **copied into the target repository** (conventionally `.gitlab/codereview/`) because a CI job cannot resolve plugin paths. Re-run the setup command to pick up plugin updates. ## Delivery modes (GitLab CI) -`AI_REVIEW_MODE` selects how findings reach the MR; see `references/review-modes.md` for detail. +`CODEREVIEW_MODE` selects how findings reach the MR; see `references/review-modes.md` for detail. | Mode | Needs | Result | |---|---|---| | `inline` (default) | `GITLAB_TOKEN` | One positioned discussion per finding plus a sticky summary note. Positions GitLab rejects degrade to plain notes. On re-push, stale bot threads are resolved and the summary updates in place. | | `summary` | `GITLAB_TOKEN` | The sticky summary note only. | -| `log` | nothing | Job log plus `ai-review-artifacts/`. Automatic fallback when `GITLAB_TOKEN` is unset. | +| `log` | nothing | Job log plus `codereview-artifacts/`. Automatic fallback when `GITLAB_TOKEN` is unset. | ## Tokens: when CI_JOB_TOKEN is not enough @@ -75,8 +75,8 @@ no token at all. The CI job carries `timeout: 15m`, `interruptible: true`, and `allow_failure: true`, so the reviewer never blocks a merge and a new push supersedes the in-flight run. The wrapper adds diff -budgets on every surface (`AI_REVIEW_MAX_FILE_LINES`, `AI_REVIEW_MAX_DIFF_LINES` -- truncations -are listed in the summary), a turn cap where the engine supports one (`AI_REVIEW_MAX_TURNS`, on +budgets on every surface (`CODEREVIEW_MAX_FILE_LINES`, `CODEREVIEW_MAX_DIFF_LINES` -- truncations +are listed in the summary), a turn cap where the engine supports one (`CODEREVIEW_MAX_TURNS`, on the `claude` engine), and, in token modes, a no-op guard: a pipeline retry on an already-reviewed head SHA exits before the engine runs. @@ -102,4 +102,4 @@ repository can carry both jobs, and neither plugin requires the other. - **`examples/mr-review-job.yml`** -- the CI job template - **`examples/review-agent.yaml`** -- the docker-agent config template - **`examples/copilot-code-review.instructions.md`** -- the Copilot instructions file -- **`examples/git-hook-pre-push.sh`** -- a pre-push hook wiring `ai-review.sh` +- **`examples/git-hook-pre-push.sh`** -- a pre-push hook wiring `codereview.sh` diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh b/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh index 0cae39a..5b38dbc 100644 --- a/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh +++ b/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh @@ -2,18 +2,18 @@ # pre-push -- AI-review the commits about to be pushed. # # Install: copy to .git/hooks/pre-push and mark executable, or point a hook -# manager (lefthook, husky, core.hooksPath) at it. Requires ai-review.sh from -# the same review bundle; adjust AI_REVIEW_SH if it lives elsewhere. +# manager (lefthook, husky, core.hooksPath) at it. Requires codereview.sh from +# the same review bundle; adjust CODEREVIEW_SH if it lives elsewhere. # # Advisory by default: findings print, the push proceeds. Set -# AI_REVIEW_BLOCKING=1 to abort the push when a blocker is found. Either way, +# CODEREVIEW_BLOCKING=1 to abort the push when a blocker is found. Either way, # `git push --no-verify` skips the hook entirely. -AI_REVIEW_SH="${AI_REVIEW_SH:-.gitlab/ai-review/ai-review.sh}" +CODEREVIEW_SH="${CODEREVIEW_SH:-.gitlab/codereview/codereview.sh}" ZERO=0000000000000000000000000000000000000000 -if [ ! -f "$AI_REVIEW_SH" ]; then - echo "pre-push: $AI_REVIEW_SH not found; skipping AI review." >&2 +if [ ! -f "$CODEREVIEW_SH" ]; then + echo "pre-push: $CODEREVIEW_SH not found; skipping AI review." >&2 exit 0 fi @@ -30,10 +30,10 @@ while read -r _local_ref local_sha _remote_ref remote_sha; do fi [ -n "$base" ] || continue - sh "$AI_REVIEW_SH" "$base...$local_sha" || status=1 + sh "$CODEREVIEW_SH" "$base...$local_sha" || status=1 done # Advisory unless the user opted into gating: an engine failure or a finding # must not strand a push by default. -[ "${AI_REVIEW_BLOCKING:-0}" = "1" ] || exit 0 +[ "${CODEREVIEW_BLOCKING:-0}" = "1" ] || exit 0 exit $status diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml b/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml index 57edb3f..36f0317 100644 --- a/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml +++ b/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml @@ -1,9 +1,9 @@ # Automated AI review of every merge request. # # Add this job to .gitlab-ci.yml (adapt `stage:` to the project) after copying -# the review bundle into .gitlab/ai-review/ -- the setup-mr-review command does +# the review bundle into .gitlab/codereview/ -- the setup-mr-review command does # both. The job runs an AI engine over the MR diff and delivers findings per -# AI_REVIEW_MODE; see review-modes.md in the mr-review-agent skill. +# CODEREVIEW_MODE; see review-modes.md in the mr-review-agent skill. # # CI/CD variables to create under Settings > CI/CD > Variables (all masked): # - The model provider key the engine needs, e.g. ANTHROPIC_API_KEY. @@ -29,8 +29,8 @@ ai-mr-review: # Tokenless (log) mode diffs locally against the MR base, which a shallow # clone cannot reach. GIT_DEPTH: "0" - AI_REVIEW_MODE: "inline" # inline | summary | log - AI_REVIEW_ENGINE: "docker-agent" # docker-agent | claude | codex | copilot + CODEREVIEW_MODE: "inline" # inline | summary | log + CODEREVIEW_ENGINE: "docker-agent" # docker-agent | claude | codex | copilot DOCKER_AGENT_VERSION: "v1.124.0" # pin; never latest DOCKER_AGENT_SHA256: "" # optional: recorded checksum of the pinned binary; verified when set TELEMETRY_ENABLED: "false" @@ -38,7 +38,7 @@ ai-mr-review: # oven/bun images carry no curl; download the pinned standalone binary # with bun itself. No Docker daemon is involved at any point. - | - if [ "$AI_REVIEW_ENGINE" = "docker-agent" ]; then + if [ "$CODEREVIEW_ENGINE" = "docker-agent" ]; then bun -e 'const v = process.env.DOCKER_AGENT_VERSION; const url = `https://github.com/docker/docker-agent/releases/download/${v}/docker-agent-linux-amd64`; const res = await fetch(url); @@ -53,12 +53,12 @@ ai-mr-review: chmod +x /usr/local/bin/docker-agent fi - | - if [ "$AI_REVIEW_ENGINE" = "claude" ]; then + if [ "$CODEREVIEW_ENGINE" = "claude" ]; then # Pin CLAUDE_CODE_VERSION to a known-good release for reproducible runs. bun add -g "@anthropic-ai/claude-code${CLAUDE_CODE_VERSION:+@$CLAUDE_CODE_VERSION}" fi script: - - bun .gitlab/ai-review/post-mr-review.ts + - bun .gitlab/codereview/post-mr-review.ts timeout: 15m # A broken or flaky reviewer must never block a merge. allow_failure: true @@ -68,4 +68,4 @@ ai-mr-review: when: always expire_in: 1 week paths: - - ai-review-artifacts/ + - codereview-artifacts/ diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml b/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml index 8205fb6..620af35 100644 --- a/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml +++ b/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml @@ -1,8 +1,8 @@ # docker-agent configuration for automated merge-request review. # -# Copied into a target repository (conventionally .gitlab/ai-review/) by the +# Copied into a target repository (conventionally .gitlab/codereview/) by the # setup-mr-review command. The full review rubric and the diff arrive in the -# prompt built by post-mr-review.ts / ai-review.sh; this file sets the model, +# prompt built by post-mr-review.ts / codereview.sh; this file sets the model, # the persona, and -- deliberately -- a read-only toolset. # # SECURITY: the diff under review is untrusted input to the model. This agent diff --git a/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md b/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md index 008939f..62bcbcf 100644 --- a/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md +++ b/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md @@ -72,7 +72,7 @@ environment (provider key included) is within reach of a prompt-injected agent w | Flag | Behavior | Use | |---|---|---| -| `--safety restricted` | Denies tool calls outside configured permissions without prompting | The default (`AI_REVIEW_ENGINE_FLAGS`) | +| `--safety restricted` | Denies tool calls outside configured permissions without prompting | The default (`CODEREVIEW_ENGINE_FLAGS`) | | `--yolo` | Auto-approves everything | Fallback if a version denies the read-only tools under `restricted`; acceptable only because the toolset is already read-only | | `--sandbox` | Runs the agent in a VM | Not usable inside typical CI containers (no nested virtualization) | diff --git a/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md b/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md index 42c5f83..c46d3c1 100644 --- a/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md +++ b/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md @@ -2,7 +2,7 @@ ## Mode matrix -`AI_REVIEW_MODE` on the CI job selects delivery. The wrapper resolves the effective mode at +`CODEREVIEW_MODE` on the CI job selects delivery. The wrapper resolves the effective mode at runtime: any comment mode without `GITLAB_TOKEN` downgrades to `log` with a notice on stderr, because `CI_JOB_TOKEN` cannot create MR notes or discussions. @@ -12,7 +12,7 @@ because `CI_JOB_TOKEN` cannot create MR notes or discussions. | `summary` | `GITLAB_TOKEN` | The summary note only: counts, a findings table with `path:line` locations, truncation notices. | | `log` | none | The rendered report in the job log, plus artifacts. | -Artifacts are written in every mode, under `ai-review-artifacts/`: +Artifacts are written in every mode, under `codereview-artifacts/`: | File | Content | |---|---| @@ -27,24 +27,24 @@ Artifacts are written in every mode, under `ai-review-artifacts/`: | `inline`, `summary` | `GET /projects/:id/merge_requests/:iid/changes` | Also supplies `diff_refs` for positioning and the server-side draft flag | | `log` | `git diff $CI_MERGE_REQUEST_DIFF_BASE_SHA...HEAD` | The base SHA must be reachable: set `GIT_DEPTH: "0"` on the job (the template does) or fetch it explicitly | -Diff budgets apply in both paths: `AI_REVIEW_MAX_FILE_LINES` (default 1500) per file and -`AI_REVIEW_MAX_DIFF_LINES` (default 6000) total. Files cut by either budget are named in the +Diff budgets apply in both paths: `CODEREVIEW_MAX_FILE_LINES` (default 1500) per file and +`CODEREVIEW_MAX_DIFF_LINES` (default 6000) total. Files cut by either budget are named in the summary so a partial review never masquerades as a full one. ## Engine matrix -| `AI_REVIEW_ENGINE` | Invocation | Auth | Notes | +| `CODEREVIEW_ENGINE` | Invocation | Auth | Notes | |---|---|---|---| | `docker-agent` | `docker-agent run --exec review-agent.yaml --json --safety restricted -` | Provider key per `model:` in the config | Default. Provider-agnostic. Standalone binary, pinned by `DOCKER_AGENT_VERSION`; no Docker daemon. | | `claude` | `claude -p --output-format json --max-turns N --allowedTools "Read Grep Glob"` | `ANTHROPIC_API_KEY`, subscription token, or the Bedrock/Vertex setups documented by the act-gitlab-ci plugin | The Docker-free pipeline path. | | `codex` | `codex exec --json` | OpenAI credentials | Best effort: flags move between versions; check `codex exec --help`. | -| `copilot` | `copilot -p ` | GitHub Copilot auth | Best effort: same caveat. The prompt travels as one argv element, so very large diffs can exceed the OS argument limit -- lower `AI_REVIEW_MAX_DIFF_LINES` or switch to `AI_REVIEW_ENGINE_CMD` with a stdin-reading invocation. | -| any | `AI_REVIEW_ENGINE_CMD` | caller's concern | Full command via `sh -c` on both surfaces (CI wrapper and local harness); prompt on stdin; must print the findings JSON. | +| `copilot` | `copilot -p ` | GitHub Copilot auth | Best effort: same caveat. The prompt travels as one argv element, so very large diffs can exceed the OS argument limit -- lower `CODEREVIEW_MAX_DIFF_LINES` or switch to `CODEREVIEW_ENGINE_CMD` with a stdin-reading invocation. | +| any | `CODEREVIEW_ENGINE_CMD` | caller's concern | Full command via `sh -c` on both surfaces (CI wrapper and local harness); prompt on stdin; must print the findings JSON. | -The local harness auto-detects an engine when `AI_REVIEW_ENGINE` is unset: the first of +The local harness auto-detects an engine when `CODEREVIEW_ENGINE` is unset: the first of `docker-agent`, `claude`, `codex`, `copilot` found on PATH. -Safety flags for docker-agent default to `--safety restricted` (`AI_REVIEW_ENGINE_FLAGS` +Safety flags for docker-agent default to `--safety restricted` (`CODEREVIEW_ENGINE_FLAGS` overrides). If a docker-agent version denies the read-only tools under `restricted`, `--yolo` is an acceptable fallback **only because** the config's toolsets are already read-only: the approval flag governs prompting, the toolset governs capability. From 0ad40f388a4cde65fb742054b1d81fcea7dad910 Mon Sep 17 00:00:00 2001 From: Daniel Bodnar <1790726+danielbodnar@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:55:18 -0500 Subject: [PATCH 5/5] refactor(code-reviews)!: rebuild as skills over existing harnesses Replaces the bespoke runner with two skills and no runtime. The review methodology - defect checklist, standing false-positive list, three severity tiers, and the bar that a finding must name a concrete failure scenario and cite path:line - becomes prose any agent on any provider can follow. Installation targets the harness each platform already ships: managed Code Review, claude-code-action, the GitLab-maintained Claude Code CI integration, the built-in review command and hooks, Copilot instruction files, and docker-agent, Codex or Copilot CLI. Configuration layers on REVIEW.md, an existing documented convention, with ACT_CODE_REVIEW.md importing it via @ for the organization layer. Posting always uses the host's own tooling. Deletes ~1,600 lines: post-mr-review.ts, codereview.sh, the eighteen CODEREVIEW_* variables, the findings-JSON contract, sticky-marker semantics, and their fixtures. ADR 0006 records the pivot and supersedes 0005. --- .claude-plugin/marketplace.json | 21 +- .github/plugin/marketplace.json | 2 +- README.md | 2 +- .../0005-mr-review-engine-agnostic.md | 4 +- .../0006-review-skills-over-harness.md | 94 +++ docs/decisions/README.md | 3 +- .../code-reviews/.claude-plugin/plugin.json | 14 +- .../code-reviews/.codex-plugin/plugin.json | 6 +- plugins/code-reviews/README.md | 174 ++-- plugins/code-reviews/commands/review-mr.md | 60 -- .../code-reviews/commands/setup-mr-review.md | 80 -- plugins/code-reviews/plugin.json | 4 +- plugins/code-reviews/scripts/codereview.sh | 186 ---- .../code-reviews/scripts/post-mr-review.ts | 796 ------------------ .../mr-review/fixtures/claude-output.json | 9 - .../mr-review/fixtures/discussions-stale.json | 20 - .../mr-review/fixtures/mr-changes-draft.json | 19 - .../tests/mr-review/fixtures/mr-changes.json | 24 - .../mr-review/fixtures/notes-with-marker.json | 14 - .../fixtures/notes-without-marker.json | 6 - .../tests/mr-review/fixtures/stub-engine.sh | 5 - .../mr-review/fixtures/stub-env-probe.sh | 18 - .../fixtures/transcript-malformed.ndjson | 4 - .../mr-review/fixtures/transcript.ndjson | 6 - .../scripts/tests/mr-review/run-tests.sh | 250 ------ .../scripts/tests/mr-review/unit.test.ts | 343 -------- .../scripts/tests/templates/run-tests.sh | 116 +++ plugins/code-reviews/skills/install/SKILL.md | 75 ++ .../install/references/copilot-native.md | 59 ++ .../install/references/github-actions.md | 67 ++ .../install/references/github-managed.md | 83 ++ .../skills/install/references/gitlab-ci.md | 87 ++ .../skills/install/references/local.md | 55 ++ .../install/references/non-claude-engines.md | 66 ++ .../install/templates/claude-code-review.yml | 54 ++ .../templates/code-review.instructions.md | 52 ++ .../templates/gitlab-ci-review-job.yml | 53 ++ .../skills/install/templates/pre-push | 48 ++ .../install/templates/review-agent.yaml | 57 ++ .../skills/mr-review-agent/SKILL.md | 105 --- .../copilot-code-review.instructions.md | 33 - .../examples/git-hook-pre-push.sh | 39 - .../examples/mr-review-job.yml | 71 -- .../examples/review-agent.yaml | 46 - .../references/copilot-code-review.md | 47 -- .../references/docker-agent-config.md | 92 -- .../references/gitlab-discussions-api.md | 85 -- .../references/review-modes.md | 83 -- .../references/review-rubric.md | 83 -- .../code-reviews/skills/review-mr/SKILL.md | 12 - plugins/code-reviews/skills/review/SKILL.md | 121 +++ .../review/references/guidance-layering.md | 59 ++ .../skills/review/references/personas.md | 50 ++ .../review/references/severity-model.md | 51 ++ .../review/references/what-to-report.md | 81 ++ .../review/templates/ACT_CODE_REVIEW.md | 50 ++ .../skills/review/templates/REVIEW.md | 47 ++ .../skills/setup-mr-review/SKILL.md | 12 - 58 files changed, 1539 insertions(+), 2664 deletions(-) create mode 100644 docs/decisions/0006-review-skills-over-harness.md delete mode 100644 plugins/code-reviews/commands/review-mr.md delete mode 100644 plugins/code-reviews/commands/setup-mr-review.md delete mode 100644 plugins/code-reviews/scripts/codereview.sh delete mode 100644 plugins/code-reviews/scripts/post-mr-review.ts delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/claude-output.json delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes-draft.json delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes.json delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-without-marker.json delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-engine.sh delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-env-probe.sh delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript.ndjson delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/run-tests.sh delete mode 100644 plugins/code-reviews/scripts/tests/mr-review/unit.test.ts create mode 100644 plugins/code-reviews/scripts/tests/templates/run-tests.sh create mode 100644 plugins/code-reviews/skills/install/SKILL.md create mode 100644 plugins/code-reviews/skills/install/references/copilot-native.md create mode 100644 plugins/code-reviews/skills/install/references/github-actions.md create mode 100644 plugins/code-reviews/skills/install/references/github-managed.md create mode 100644 plugins/code-reviews/skills/install/references/gitlab-ci.md create mode 100644 plugins/code-reviews/skills/install/references/local.md create mode 100644 plugins/code-reviews/skills/install/references/non-claude-engines.md create mode 100644 plugins/code-reviews/skills/install/templates/claude-code-review.yml create mode 100644 plugins/code-reviews/skills/install/templates/code-review.instructions.md create mode 100644 plugins/code-reviews/skills/install/templates/gitlab-ci-review-job.yml create mode 100644 plugins/code-reviews/skills/install/templates/pre-push create mode 100644 plugins/code-reviews/skills/install/templates/review-agent.yaml delete mode 100644 plugins/code-reviews/skills/mr-review-agent/SKILL.md delete mode 100644 plugins/code-reviews/skills/mr-review-agent/examples/copilot-code-review.instructions.md delete mode 100644 plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh delete mode 100644 plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml delete mode 100644 plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml delete mode 100644 plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md delete mode 100644 plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md delete mode 100644 plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md delete mode 100644 plugins/code-reviews/skills/mr-review-agent/references/review-modes.md delete mode 100644 plugins/code-reviews/skills/mr-review-agent/references/review-rubric.md delete mode 100644 plugins/code-reviews/skills/review-mr/SKILL.md create mode 100644 plugins/code-reviews/skills/review/SKILL.md create mode 100644 plugins/code-reviews/skills/review/references/guidance-layering.md create mode 100644 plugins/code-reviews/skills/review/references/personas.md create mode 100644 plugins/code-reviews/skills/review/references/severity-model.md create mode 100644 plugins/code-reviews/skills/review/references/what-to-report.md create mode 100644 plugins/code-reviews/skills/review/templates/ACT_CODE_REVIEW.md create mode 100644 plugins/code-reviews/skills/review/templates/REVIEW.md delete mode 100644 plugins/code-reviews/skills/setup-mr-review/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0285384..2b7f7d4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -162,8 +162,8 @@ "name": "code-reviews", "source": "./plugins/code-reviews", "displayName": "Code Reviews", - "description": "Automated AI code review across GitHub, GitLab, and local agents. One review rubric and findings contract drive four surfaces: a GitLab CI job that reviews every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) posting inline discussions and a sticky summary with a tokenless log fallback; a git pre-push hook with an engine-dispatching harness; an in-session review command; and GitHub Copilot native review instructions. Posting is deterministic script code, never an agent tool call, and the reviewer never blocks a merge. Relevant to anyone automating merge request or pull request review, wiring an AI reviewer into a pipeline or git hook, or configuring Copilot code review.", - "version": "0.1.0", + "description": "Automated code review as skills, not a runner. One methodology skill carries the review rubric - the defect checklist, the standing false-positive list, three severity tiers, and the bar that a finding must name a concrete failure scenario and cite path:line - and reads REVIEW.md, ACT_CODE_REVIEW.md, CLAUDE.md and AGENTS.md as layered guidance on any host or provider. A second skill installs that review into a harness that already exists: Anthropic's managed Code Review, GitHub Actions with claude-code-action, the GitLab-maintained Claude Code CI integration, local hooks and the built-in review command, GitHub Copilot instructions files, or docker-agent, Codex and Copilot CLI. Ships no runtime and invents no configuration syntax. Relevant to anyone reviewing a pull or merge request, automating review in a pipeline, tuning what a reviewer flags, or configuring Copilot code review.", + "version": "0.2.0", "author": { "name": "Daniel Bodnar" }, @@ -171,23 +171,24 @@ "category": "engineering", "keywords": [ "code-review", - "merge-requests", "pull-requests", - "gitlab", + "merge-requests", "github", - "docker-agent", - "git-hooks", + "gitlab", "copilot", "ci-cd", - "automation" + "git-hooks", + "automation", + "quality" ], "relevance": { - "topic": "automated code review of merge requests and pull requests", + "topic": "code review of pull requests and merge requests", "signals": { "filesRead": [ - "**/.gitlab/codereview/**", + "**/REVIEW.md", + "**/ACT_CODE_REVIEW.md", "**/.github/instructions/**", - "**/.gitlab-ci.yml", + "**/.github/workflows/*review*.yml", "**/hooks/pre-push*" ] } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a93eba6..4b66039 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -7,6 +7,6 @@ { "name": "act-platform-engineering", "description": "Assess and operate PostgreSQL, ZFS, Linux, Proxmox VE, and observability systems.", "version": "0.2.0", "source": "./plugins/act-platform-engineering", "category": "Operations" }, { "name": "act-work-tracking", "description": "Draft Zoho Projects work and engineering status reports.", "version": "0.2.0", "source": "./plugins/act-work-tracking", "category": "Workflow" }, { "name": "act-gitlab-ci", "description": "Build, review, and troubleshoot GitLab CI/CD integrations.", "version": "0.2.1", "source": "./plugins/act-gitlab-ci", "category": "Engineering" }, - { "name": "code-reviews", "description": "Run automated AI code review on merge requests, pull requests, git hooks, and in-session.", "version": "0.1.0", "source": "./plugins/code-reviews", "category": "Engineering" } + { "name": "code-reviews", "description": "Perform high-quality code review on any host, and install it into GitHub, GitLab, or local harnesses.", "version": "0.2.0", "source": "./plugins/code-reviews", "category": "Engineering" } ] } diff --git a/README.md b/README.md index f83aaae..bfeb72c 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ copilot plugin install act-plugin-dev@actdata-plugins | **[`act-platform-engineering`](plugins/act-platform-engineering/)**
Operations | Assessment and operations for PostgreSQL, ZFS, Linux hosts and Proxmox VE. | 23 skills · 7 agents · 9 commands | | **[`act-work-tracking`](plugins/act-work-tracking/)**
Workflow | Zoho Projects work tracking and operations reporting. | 6 skills · 1 agent · 3 commands | | **[`act-gitlab-ci`](plugins/act-gitlab-ci/)**
Engineering | GitLab CI/CD jobs, MCP, authentication, troubleshooting, and pipeline standards. | 10 skills · 1 agent · 3 commands · 1 MCP | -| **[`code-reviews`](plugins/code-reviews/)**
Engineering | Automated AI code review across GitHub, GitLab, and local agents: CI merge-request review with swappable engines, a pre-push hook, an in-session command, and Copilot review instructions. | 3 skills · 2 commands · 2 scripts | +| **[`code-reviews`](plugins/code-reviews/)**
Engineering | The code review methodology as a skill, plus installation into an existing harness: managed Code Review, GitHub Actions, GitLab CI, local hooks, or Copilot instructions. No runtime. | 2 skills · 10 references · 7 templates | ### Not yet shipped diff --git a/docs/decisions/0005-mr-review-engine-agnostic.md b/docs/decisions/0005-mr-review-engine-agnostic.md index f7ca498..3abdd2c 100644 --- a/docs/decisions/0005-mr-review-engine-agnostic.md +++ b/docs/decisions/0005-mr-review-engine-agnostic.md @@ -1,6 +1,6 @@ # 5. Ship automated code review as an engine-agnostic contract in a standalone code-reviews plugin -- **Status:** Accepted +- **Status:** Superseded by [0006](0006-review-skills-over-harness.md) - **Date:** 2026-08-15 ## Context @@ -58,7 +58,7 @@ plain notes rather than being dropped. scripts strip every GitLab token from the engine's environment; timeouts, `allow_failure: true`, `interruptible: true`, diff budgets, and turn caps bound cost. The reviewer never blocks a merge. -**4. Scripts are copied into target repositories** (`.gitlab/codereview/`) by the +**4. Scripts are copied into target repositories** (`.codereview/`) by the `setup-mr-review` command, because CI jobs cannot resolve `${CLAUDE_PLUGIN_ROOT}`. The canonical, tested copies stay in the plugin; re-running the command refreshes them. diff --git a/docs/decisions/0006-review-skills-over-harness.md b/docs/decisions/0006-review-skills-over-harness.md new file mode 100644 index 0000000..aa9209f --- /dev/null +++ b/docs/decisions/0006-review-skills-over-harness.md @@ -0,0 +1,94 @@ +# 6. Ship review as skills over existing harnesses, and build no runner + +- **Status:** Accepted +- **Date:** 2026-08-15 +- **Supersedes:** [0005](0005-mr-review-engine-agnostic.md) + +## Context + +ADR 0005 shipped automated review as an engine-agnostic contract with a deterministic runner: a +TypeScript poster, a POSIX engine-dispatch harness, an eighteen-variable `CODEREVIEW_*` +environment DSL, a findings-JSON schema, sticky-marker semantics, and a fixture suite pinning all +of it — roughly 1,600 lines. + +The reasoning in 0005 was sound given its premise: GitLab's discussion-position API is exacting, a +model gets it wrong at a steady rate, and putting that in deterministic code makes it testable. +What the premise missed is that **the harness was never the missing piece.** Reviewing the field +turned up a vendor-supported runner for every surface the requirement named: + +| Surface | Harness that already exists | +|---|---| +| GitHub, managed | Claude GitHub App: inline comments, severity tiers, a neutral check run, `@claude review` | +| GitHub, self-hosted | `anthropics/claude-code-action` with `mcp__github_inline_comment__create_inline_comment` | +| GitLab | GitLab-maintained Claude Code CI/CD integration: `claude -p` plus `mcp__gitlab` tools from `/bin/gitlab-mcp-server` | +| Local | The built-in `/code-review` skill, with `--comment`, `--fix`, and effort levels | +| Any engine | docker-agent, `codex exec`, Copilot CLI — each accepts a prompt | + +Two further findings settled it. Anthropic's own `code-review` plugin — the one this repository's +CI already runs — is **a single markdown command file with zero code**. And configuration has an +established convention: **`REVIEW.md`** at repository root, freeform markdown, injected verbatim as +the highest-priority instruction block, with documented tunables for severity, nit caps, skip +rules, repo-specific checks, verification bar, re-review convergence, and summary shape. + +Against that, the 0005 design was building a second-rate copy of infrastructure that already +worked, and teaching operators a configuration language nobody else speaks. + +## Options considered + +| Option | Assessment | +|---|---| +| **Keep the runner, fix its defects** | The defects were real and fixable — a marker-kind collision, an argv limit, inconsistent exit codes — and all were found and fixed. Rejected anyway: a correct implementation of an unnecessary component is still unnecessary, and every fix widened the DSL operators must learn. | +| **Keep the runner as an opt-in advanced path** | Rejected. A documented escape hatch is still shipped, still maintained, still the thing a hurried operator reaches for. Retaining it preserves exactly what is being removed. | +| **Skills plus templates over existing harnesses** | Chosen. The methodology is the differentiated asset and is pure prose; the harnesses are commodity and already supported by their vendors. | +| **Invent a plugin-owned config file** | Rejected. `REVIEW.md` exists, is documented, and is read natively by the managed product. A competing file would fragment configuration for no gain. | + +## Decision + +Rebuild `plugins/code-reviews` as two skills and no runtime. + +**1. `review` carries the methodology.** The defect checklist, the standing false-positive list, +three severity tiers matching what the managed product emits, and one verification bar: a finding +must name a concrete failure scenario — specific inputs or state producing a wrong result — and +cite `path:line` in code actually read. Candidates failing either test are dropped silently. + +**2. `install` wires the review into a harness**, one reference and one template per surface, each +using that harness's native mechanism. It writes configuration and names the credentials the +operator must create; it never handles a value. + +**3. Configuration layers on `REVIEW.md`.** `ACT_CODE_REVIEW.md` opens with `@REVIEW.md` and adds +the organization layer. Precedence runs methodology → `REVIEW.md` → `ACT_CODE_REVIEW.md` → +`CLAUDE.md`/`AGENTS.md`. Both are plain markdown and `@` is Claude Code's existing import syntax; +nothing new is invented. + +**4. Posting always uses the host's tools** — the inline-comment MCP server under +claude-code-action, `mcp__gitlab` in GitLab CI, `gh`/`glab` locally — and only when the invocation +asked for it. Terminal output is the default. + +**5. `commands/` is dropped.** Custom commands have merged into skills upstream, so the repository's +command-plus-shim pattern would duplicate every entry point for no benefit in a new plugin. + +## Consequences + +**Position accuracy is now the model's job, not deterministic code's.** This is the real cost of +the pivot and it should not be glossed: 0005's poster constructed `position` objects from +`diff_refs` and degraded to a plain note on rejection, which was testable offline. Now each harness +posts through its own tooling and accuracy is theirs to maintain. The tradeoff is accepted because +those vendors own the API contract, fix it faster, and already handle the failure modes — and +because unverifiable local behavior was being pinned by fixtures that only ever tested our own code. + +**The test suite shrinks to what can rot.** No runtime means nothing to unit test. What remains +checks that shipped templates parse, that instruction files carry the frontmatter Copilot requires, +that every referenced resource exists, and that no runtime has crept back into the plugin. + +**Guidance must be flattened for two surfaces.** The managed product reads `REVIEW.md` verbatim and +does not expand `@`; Copilot reads its own instructions file. Both receive generated, marked files, +and regeneration is a manual step an operator can forget. Divergence there is silent, which is why +the generated header names its source. + +**The plugin can no longer promise identical output everywhere.** Copilot grades in its own +vocabulary; the managed product runs its own multi-agent pipeline. This plugin steers those +surfaces rather than controlling them, and the README says so rather than implying parity. + +**Roughly 1,600 lines were deleted, including work completed the same day.** Recorded plainly +because the alternative — keeping it to justify having written it — is the failure mode this ADR +exists to prevent. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c3f611c..c3f4303 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -15,7 +15,8 @@ what it cost to arrive at, needs one. | [0002](0002-config-driven-plugins.md) | Ship no environment identifiers; read them from a site-local settings file | Accepted | | [0003](0003-three-plugin-split.md) | Split the operational bundle into three plugins | Accepted | | [0004](0004-derived-pipeline-standards.md) | Ship pipeline standards as explicitly derived, and record the platform conflict | Accepted | -| [0005](0005-mr-review-engine-agnostic.md) | Ship automated code review as an engine-agnostic contract in a standalone code-reviews plugin | Accepted | +| [0005](0005-mr-review-engine-agnostic.md) | Ship automated code review as an engine-agnostic contract in a standalone code-reviews plugin | Superseded by [0006](0006-review-skills-over-harness.md) | +| [0006](0006-review-skills-over-harness.md) | Ship review as skills over existing harnesses, and build no runner | Accepted | ## Format diff --git a/plugins/code-reviews/.claude-plugin/plugin.json b/plugins/code-reviews/.claude-plugin/plugin.json index 96f9f49..c3df52b 100644 --- a/plugins/code-reviews/.claude-plugin/plugin.json +++ b/plugins/code-reviews/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "code-reviews", - "version": "0.1.0", - "description": "Automated AI code review across GitHub, GitLab, and local agents. One review rubric and findings contract drive four surfaces: a GitLab CI job that reviews every merge request through swappable engines (docker-agent, Claude Code, Codex, Copilot) with inline discussions and a sticky summary, a git pre-push hook, an in-session review command, and GitHub Copilot native review instructions. Delivery is deterministic script code, never an agent tool call, with a tokenless log-mode fallback.", + "version": "0.2.0", + "description": "Automated code review as skills, not a runner. One methodology skill carries the review rubric - severity tiers, the defect checklist, the standing false-positive list, and the verification bar that a finding must name a concrete failure scenario and cite path:line - and reads REVIEW.md, ACT_CODE_REVIEW.md, CLAUDE.md and AGENTS.md as layered guidance on any host or provider. A second skill installs that review into an existing harness: Anthropic's managed Code Review, GitHub Actions, GitLab CI, local hooks, GitHub Copilot instructions, or docker-agent, Codex and Copilot CLI. Ships no runtime and invents no configuration syntax.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com" @@ -12,14 +12,14 @@ "license": "LicenseRef-ACT-Internal", "keywords": [ "code-review", - "merge-requests", "pull-requests", - "gitlab", + "merge-requests", "github", - "docker-agent", - "git-hooks", + "gitlab", "copilot", "ci-cd", - "automation" + "git-hooks", + "automation", + "quality" ] } diff --git a/plugins/code-reviews/.codex-plugin/plugin.json b/plugins/code-reviews/.codex-plugin/plugin.json index 29643e6..9ee1c55 100644 --- a/plugins/code-reviews/.codex-plugin/plugin.json +++ b/plugins/code-reviews/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "code-reviews", "version": "0.1.0", "description": "Automated AI code review for merge requests, pull requests, git hooks, and interactive sessions.", + "name": "code-reviews", "version": "0.2.0", "description": "Code review methodology and harness installation for pull requests, merge requests, and local hooks.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", - "keywords": ["code-review", "merge-requests", "pull-requests", "gitlab", "github", "automation"], "skills": "./skills/", - "interface": { "displayName": "Code Reviews", "shortDescription": "Automated AI code review for MRs, PRs, hooks, and sessions", "longDescription": "Install an engine-agnostic AI reviewer: a GitLab CI job posting inline merge-request discussions, a git pre-push hook, an in-session review command, and GitHub Copilot native review instructions, all driven by one rubric and findings contract.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Set up automated AI review of merge requests.", "Review this merge request against the shared rubric."] } + "keywords": ["code-review", "pull-requests", "merge-requests", "github", "gitlab", "automation"], "skills": "./skills/", + "interface": { "displayName": "Code Reviews", "shortDescription": "High-quality code review, on any harness", "longDescription": "Carries the review methodology - severity tiers, the defect checklist, the false-positive list, and the verification bar - reading REVIEW.md and ACT_CODE_REVIEW.md as layered guidance, plus an install skill that wires the review into managed Code Review, GitHub Actions, GitLab CI, local hooks, Copilot instructions, or a non-Claude engine.", "developerName": "ACT Data", "category": "Engineering", "capabilities": ["Read", "Write"], "websiteURL": "https://github.com/patterson-agents/actdata-plugins", "defaultPrompt": ["Review this merge request.", "Set up automated code review on every pull request."] } } diff --git a/plugins/code-reviews/README.md b/plugins/code-reviews/README.md index 79b5a26..ce403c4 100644 --- a/plugins/code-reviews/README.md +++ b/plugins/code-reviews/README.md @@ -7,12 +7,11 @@ # code-reviews -Automated AI code review across GitHub, GitLab, and local agents. +High-quality code review as skills, installable into the harness you already have. -![skills](https://img.shields.io/badge/skills-3-00A8E1?labelColor=003767) -![agents](https://img.shields.io/badge/agents-0-003767) -![commands](https://img.shields.io/badge/commands-2-147EC2) -![scripts](https://img.shields.io/badge/scripts-2-00817D) +![skills](https://img.shields.io/badge/skills-2-00A8E1?labelColor=003767) +![templates](https://img.shields.io/badge/templates-7-147EC2) +![runtime](https://img.shields.io/badge/runtime-none-003767) ![deps](https://img.shields.io/badge/dependencies-none-58585B) @@ -24,115 +23,108 @@ Automated AI code review across GitHub, GitLab, and local agents. - [What this is](#what-this-is) - [What ships](#what-ships) - [Skills](#skills) -- [Commands](#commands) +- [Guidance layering](#guidance-layering) +- [Harnesses](#harnesses) - [Install](#install) -- [How a review runs](#how-a-review-runs) -- [Configuration](#configuration) -- [Security model](#security-model) - [What this plugin does NOT do](#what-this-plugin-does-not-do) - [Layout](#layout) ## What this is -One review rubric and one findings contract, delivered on four surfaces: +Two things, deliberately separated: -| Surface | How | Setup | -|---|---|---| -| GitLab CI, every MR | A job runs an AI engine over the diff and posts the review | `/code-reviews:setup-mr-review gitlab-ci` | -| Git hook / scripts | `codereview.sh` reviews a local diff with whatever engine is on PATH | `/code-reviews:setup-mr-review git-hook` | -| In-session | `/code-reviews:review-mr` on any host the plugin is installed in | none | -| GitHub Copilot native review | A `.github/instructions/` file carries the rubric | `/code-reviews:setup-mr-review copilot` | +1. **The review methodology** — what a reviewer looks for, what it stays silent about, how a + finding earns its place, and how severity is graded. Provider-agnostic and harness-agnostic + prose, so any agent that can read files can follow it. +2. **Installation into an existing harness** — GitHub, GitLab, or local, each using its own native + mechanism. + +The review instructions and the thing that executes them are separate concerns. This plugin owns +the first and configures the second; it is not a runner. -Engines are swappable (`CODEREVIEW_ENGINE`): `docker-agent` (default, provider-agnostic, a pinned -standalone binary -- no Docker daemon), `claude`, `codex`, `copilot`, or any command via -`CODEREVIEW_ENGINE_CMD`. The engine only ever produces findings; deterministic script code does -all posting. +> [!IMPORTANT] +> There is no runtime here. No poster script, no engine dispatcher, no environment-variable +> configuration language. Every surface is driven by its own vendor-supported harness, and +> customization uses `REVIEW.md`, an existing convention, rather than a file format invented here. ## What ships | Component | Count | What it is | |---|---|---| -| Skills | 3 | The review knowledge skill plus portable adapters for both commands | -| Commands | 2 | Install a review surface; review one MR in-session | -| Scripts | 2 | The CI wrapper (`post-mr-review.ts`) and the engine-dispatch harness (`codereview.sh`), with a fixture-driven test suite | -| Templates | 4 | CI job, docker-agent config, Copilot instructions file, pre-push hook | +| Skills | 2 | `review` (perform one) and `install` (wire one up) | +| References | 10 | The methodology in depth, and one per harness | +| Templates | 7 | Two guidance files, three CI/hook configs, one Copilot instructions file, one docker-agent config | +| Runtime | 0 | By design | ## Skills -| Skill | What it covers | +| Skill | Does | |---|---| -| [`mr-review-agent`](skills/mr-review-agent/) | The rubric and findings contract, engines, delivery modes, tokens, re-push semantics. Five references, four templates. | +| [`/code-reviews:review`](skills/review/) | Reviews a change against the layered guidance and reports findings; posts only when asked, through the host's own tooling | +| [`/code-reviews:install`](skills/install/) | Sets up the guidance files, then wires the review into a chosen harness | -Plus `setup-mr-review` and `review-mr` adapter skills, which make both commands reachable from -non-Claude hosts. +## Guidance layering -## Commands +Four layers, each overriding the one before it. All are plain markdown. -| Command | Does | -|---|---| -| `/code-reviews:setup-mr-review` | Install automated review as a CI MR job, a git hook, or Copilot instructions | -| `/code-reviews:review-mr` | Review one merge request in-session against the shared rubric | +| Layer | File | Purpose | +|---|---|---| +| Base | this plugin's `review` skill | The methodology | +| Repository | `REVIEW.md` | Review policy: severity recalibration, nit caps, skip rules, repo-specific checks | +| Organization | `ACT_CODE_REVIEW.md` | The ACT layer; opens with `@REVIEW.md` so it extends rather than replaces | +| Project | `CLAUDE.md`, `AGENTS.md` | How to work in this codebase | -## Install +`REVIEW.md` is Anthropic's documented convention and is read natively by managed Code Review. The +`review` skill is what makes it portable: it reads the same file on every other surface, which +nothing else does. -```sh -claude plugin marketplace add patterson-agents/actdata-plugins -claude plugin install code-reviews@actdata-plugins -``` +> [!NOTE] +> `REVIEW.md` is pasted verbatim by the managed product, so `@` imports are not expanded there. +> Surfaces that cannot follow `@` receive a generated, clearly-marked flattened file, and `install` +> tells you the regeneration step. -## How a review runs +## Harnesses -1. The wrapper collects the diff -- from the GitLab API in comment modes, from local git in - tokenless mode -- and caps it with per-file and total budgets. Truncations are named in the - summary so a partial review never poses as a full one. -2. The selected engine reviews the diff against the rubric and emits the findings contract: - `{summary, findings: [{path, new_line, old_line, severity, title, body}]}`. -3. Delivery per `CODEREVIEW_MODE`: - -| Mode | Requires | Result | +| Surface | Harness | Posting mechanism | |---|---|---| -| `inline` (default) | `GITLAB_TOKEN` | A positioned discussion per finding plus a sticky summary note; positions GitLab rejects degrade to plain notes. On re-push, stale bot threads are resolved and the summary updates in place. | -| `summary` | `GITLAB_TOKEN` | The sticky summary note only. | -| `log` | nothing | Job log plus artifacts. Automatic fallback when `GITLAB_TOKEN` is unset. | - -The reviewer never blocks a merge: the job runs `allow_failure: true`, and the pre-push hook is -advisory unless `CODEREVIEW_BLOCKING=1`. +| GitHub, managed | Claude GitHub App | Native inline comments and a neutral check run | +| GitHub, self-hosted | `anthropics/claude-code-action` | `mcp__github_inline_comment__create_inline_comment` | +| GitLab | GitLab-maintained Claude Code CI/CD integration | `mcp__gitlab` tools from `/bin/gitlab-mcp-server` | +| Local | Built-in `/code-review`, or a pre-push hook | Terminal output | +| GitHub Copilot | Copilot's native reviewer | Copilot's own comments | +| Other engines | docker-agent, Codex, Copilot CLI | Terminal output | -## Configuration - -CI/CD variables, all masked, created by the user (the plugin never handles values): +## Install -| Variable | Required | Purpose | -|---|---|---| -| Provider API key (e.g. `ANTHROPIC_API_KEY`) | yes | Whatever key the chosen engine's `model:` needs | -| `GITLAB_TOKEN` | for `inline`/`summary` | Project access token, `api` scope, Developer role. `CI_JOB_TOKEN` cannot create MR notes. | -| `CODEREVIEW_MODE`, `CODEREVIEW_ENGINE` | no | Defaults: `inline`, `docker-agent` | -| `DOCKER_AGENT_VERSION`, `DOCKER_AGENT_SHA256` | no | Pinned binary release and optional checksum | +```sh +claude plugin marketplace add patterson-agents/actdata-plugins +claude plugin install code-reviews@actdata-plugins +``` -## Security model +Then, in the repository to be reviewed: -> [!CAUTION] -> The diff under review is untrusted input to an unattended model. The shipped docker-agent config -> is read-only (no shell, no network, no MCP); both scripts strip `GITLAB_TOKEN`, -> `GITLAB_ACCESS_TOKEN`, and `CI_JOB_TOKEN` from every engine's environment; posting is -> deterministic script code, never an agent tool call. Do not expose the GitLab token or the model -> API key to pipelines from forks. +```text +/code-reviews:install +``` ## What this plugin does NOT do > [!CAUTION] -> `setup-mr-review` writes to your repository (`.gitlab-ci.yml`, `.gitlab/codereview/`, -> `.github/instructions/`, hooks). `review-mr` posts to an MR only on explicit confirmation. - -- **No credential handling.** Variables are set in the GitLab or GitHub UI; the plugin tells the - user which to create and cannot create them itself. -- **The reviewer never gates a merge.** Generated findings are advice to verify, not policy. -- **Copilot native reviews are configured, not executed.** The instructions file only takes effect - where Copilot code review is enabled on the GitHub side. -- **No webhook or mention-driven triggering.** MR-event pipelines are native GitLab behavior; - comment-driven triggering needs a listener this plugin does not build. -- **No pipeline standards authority.** GitLab pipeline security review lives in the - `act-gitlab-ci` plugin; neither plugin requires the other. +> `install` writes to your repository — `REVIEW.md`, workflow and pipeline files, hooks, +> `.github/instructions/`. `review` posts to a pull or merge request only when explicitly asked. + +- **It is not a harness.** It does not run agents, dispatch engines, or post comments through code + of its own. Where no supported harness exists, the answer is to use one, not to add a runner here. +- **No credential handling.** It names the variables to create and where; it never reads or writes + a value. +- **Reviews never gate a merge.** Findings are advice to verify. The CI templates run + non-blocking and the pre-push hook is advisory. +- **Copilot reviews are configured, not executed.** The instructions file steers Copilot's + reviewer; it does not control what Copilot flags or how it grades. +- **No webhook infrastructure.** GitLab does not run a job on a comment natively, and this plugin + does not build the listener that would. +- **Not a quality guarantee.** A clean review means nothing obvious was found by one probabilistic + pass. ## Layout @@ -140,13 +132,17 @@ CI/CD variables, all masked, created by the user (the plugin never handles value code-reviews/ .claude-plugin/plugin.json README.md - commands/ - setup-mr-review.md review-mr.md - scripts/ - post-mr-review.ts codereview.sh - tests/mr-review/ run-tests.sh unit.test.ts fixtures/ skills/ - mr-review-agent/ SKILL.md + references/(5) + examples/(4) - setup-mr-review/ SKILL.md (adapter) - review-mr/ SKILL.md (adapter) + review/ + SKILL.md + references/ what-to-report.md severity-model.md + guidance-layering.md personas.md + templates/ REVIEW.md ACT_CODE_REVIEW.md + install/ + SKILL.md + references/ github-managed.md github-actions.md gitlab-ci.md + local.md copilot-native.md non-claude-engines.md + templates/ claude-code-review.yml gitlab-ci-review-job.yml + pre-push code-review.instructions.md review-agent.yaml + scripts/tests/templates/run-tests.sh ``` diff --git a/plugins/code-reviews/commands/review-mr.md b/plugins/code-reviews/commands/review-mr.md deleted file mode 100644 index 82554ae..0000000 --- a/plugins/code-reviews/commands/review-mr.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -description: Review a GitLab merge request against the shared rubric, in-session, and optionally post the findings -argument-hint: "[MR IID, MR URL, or a git range]" -allowed-tools: Read, Bash, Grep, Glob ---- - -# Review a merge request in-session - -Apply the review rubric to a merge request (or a local range) and report findings in the -conversation. The same rubric drives the CI job and the git hook; this is the surface for -reviewing one MR on demand, with no pipeline involved. - -## Resolve the target - -From `$ARGUMENTS`: - -- An MR IID or URL: use it directly. -- A git range (contains `..`): review the local diff of that range and skip the GitLab steps. -- Nothing: find the MR for the current branch with `glab mr view --output json`, and if there is - none, fall back to the local diff against the default branch. - -## Obtain the diff - -Prefer, in order: - -1. `glab mr diff ` when the glab CLI is authenticated. -2. The API: `GET /projects/:id/merge_requests/:iid/changes` via `glab api`, which also returns - `diff_refs` (needed later for posting inline). -3. A local `git diff ...` when GitLab is unreachable. - -Also fetch the MR title and description; intent matters when judging a diff. - -## Apply the rubric - -Read `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/references/review-rubric.md` and follow it -exactly: changed lines only, the listed defect classes in priority order, the exclusions, and the -blocker/warning/nit severity discipline. Read surrounding files from the checkout when a changed -line cannot be judged alone. - -## Report - -Present the findings in the conversation: a one-paragraph summary, then each finding as -`[SEVERITY] path:line -- title` with the failure scenario and a concrete suggestion. A clean -review says so explicitly. Never pad: an empty findings list is a valid, common outcome. - -## Posting is opt-in - -After reporting, offer to post the review to the MR -- and only proceed on an explicit yes: - -- With the glab CLI or a `GITLAB_TOKEN` available, post one summary note. Append the marker line - `` so the CI job's sticky-note - logic recognizes and updates it instead of duplicating it. Say plainly that this marker also - makes the CI review job treat the current head as already reviewed and skip its own run - (including inline discussions) until the next push -- posting an in-session review supersedes - the automated one for that revision. Inline positioned discussions from an interactive - session are rarely worth the fragility; the CI job owns that surface. -- With no credentials, say so and leave the review in the conversation. - -Never post without asking, and never include anything in the note that did not appear in the -reported findings. diff --git a/plugins/code-reviews/commands/setup-mr-review.md b/plugins/code-reviews/commands/setup-mr-review.md deleted file mode 100644 index 274fa80..0000000 --- a/plugins/code-reviews/commands/setup-mr-review.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -description: Install automated AI code review, as a GitLab CI MR job, a git hook, or GitHub Copilot review instructions -argument-hint: "[gitlab-ci|copilot|git-hook] [inline|summary|log] [docker-agent|claude|codex|copilot]" -allowed-tools: Read, Write, Edit, Bash, Grep, Glob ---- - -# Set up automated code review - -Install the review bundle from the `mr-review-agent` skill into the current repository, on one of -three surfaces. Load that skill first for the architecture, mode matrix, and security constraints. - -## Choose the surface - -From the first word of `$ARGUMENTS`, or ask: - -| Surface | When | -|---|---| -| `gitlab-ci` | Review every merge request in the pipeline. The primary surface. | -| `git-hook` | Review locally before pushing; no CI, no tokens. | -| `copilot` | The repository is reviewed on GitHub by Copilot's native reviewer. | - -## gitlab-ci - -1. Copy from the plugin into the repository at `.gitlab/codereview/`: - - `${CLAUDE_PLUGIN_ROOT}/scripts/post-mr-review.ts` - - `${CLAUDE_PLUGIN_ROOT}/scripts/codereview.sh` - - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/references/review-rubric.md` - - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/review-agent.yaml` - - The copy is deliberate: a CI job cannot resolve plugin paths. Re-running this command later - refreshes the copies. - -2. Read the repository's `.gitlab-ci.yml` if there is one and match its stage names and - conventions. Then add the job from - `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/mr-review-job.yml`, adapting: - - `CODEREVIEW_MODE` from the second argument (default `inline`). - - `CODEREVIEW_ENGINE` from the third argument (default `docker-agent`). - - Keep `timeout`, `allow_failure: true`, `interruptible: true`, and the draft-skip rules; they - are cost and safety bounds, not decoration. - -3. If the engine is `docker-agent`, set `model:` in `.gitlab/codereview/review-agent.yaml` to the - user's provider and model. Ask rather than guess the provider. - -4. Tell the user which CI/CD variables to create under Settings, CI/CD, Variables. Never handle - the values: - - The model provider key (for example `ANTHROPIC_API_KEY`), masked. - - `GITLAB_TOKEN` for `inline` or `summary` mode: a project access token, `api` scope, - Developer role, masked. State plainly that without it the review lands in the job log and - artifacts only, and that neither variable may be exposed to fork pipelines. - -5. Recommend the first run: open a test MR and check the job log, the artifacts, and the posted - review before trusting it on real work. - -## git-hook - -1. Copy `post-mr-review.ts`, `codereview.sh`, `review-rubric.md`, and `review-agent.yaml` to - `.gitlab/codereview/` as above (the harness and hook resolve them there). -2. Install `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/git-hook-pre-push.sh`: - - If the repository uses a hook manager (lefthook, husky, `core.hooksPath`), add it there and - say where. - - Otherwise copy it to `.git/hooks/pre-push` and mark it executable. Note that `.git/hooks` is - per-clone and not versioned, so each contributor installs it themselves. -3. State the defaults: advisory (findings print, the push proceeds), `CODEREVIEW_BLOCKING=1` gates, - `git push --no-verify` bypasses, and the engine is auto-detected from PATH unless - `CODEREVIEW_ENGINE` is set. - -## copilot - -1. Copy `${CLAUDE_PLUGIN_ROOT}/skills/mr-review-agent/examples/copilot-code-review.instructions.md` - to `.github/instructions/code-review.instructions.md`. -2. Tell the user what the file cannot do by itself: Copilot code review must be enabled for the - repository, the custom-instructions toggle under Settings, Copilot, Code review must be on, - and only pull requests whose head branch contains the file are affected. - -## Review before finishing - -For the `gitlab-ci` surface, re-read the resulting `.gitlab-ci.yml` and confirm the job kept its -bounds (`timeout`, `allow_failure: true`, `interruptible: true`, the draft-skip rules). If the -`act-gitlab-ci` plugin is installed, also run its `/act-gitlab-ci:review-pipeline` command over -the result and report which findings came from this change. diff --git a/plugins/code-reviews/plugin.json b/plugins/code-reviews/plugin.json index 36c63ff..1f2fd7e 100644 --- a/plugins/code-reviews/plugin.json +++ b/plugins/code-reviews/plugin.json @@ -1,6 +1,6 @@ { - "name": "code-reviews", "version": "0.1.0", "description": "Run automated AI code review on merge requests, pull requests, git hooks, and in-session.", + "name": "code-reviews", "version": "0.2.0", "description": "Perform high-quality code review on any host, and install it into GitHub, GitLab, or local harnesses.", "author": { "name": "Daniel Bodnar", "email": "dbodnar@pattersonvet.com", "url": "https://github.com/patterson-agents" }, "homepage": "https://github.com/patterson-agents/actdata-plugins", "repository": "https://github.com/patterson-agents/actdata-plugins", "license": "LicenseRef-ACT-Internal", - "keywords": ["code-review", "merge-requests", "pull-requests", "gitlab", "github", "automation"], "skills": "skills/", "commands": "commands/" + "keywords": ["code-review", "pull-requests", "merge-requests", "github", "gitlab", "automation"], "skills": "skills/" } diff --git a/plugins/code-reviews/scripts/codereview.sh b/plugins/code-reviews/scripts/codereview.sh deleted file mode 100644 index d57f0fd..0000000 --- a/plugins/code-reviews/scripts/codereview.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/bin/sh -# ============================================================================= -# codereview -- run the review rubric over a local diff with whatever AI engine -# is installed, and print the findings. -# -# The engine-agnostic harness for scripted surfaces: git hooks, ad-hoc runs, -# and anywhere the GitLab CI wrapper (post-mr-review.ts) does not apply. It -# never talks to GitLab; it reviews a local diff and reports to stdout. -# -# Usage: -# codereview.sh [git diff arguments] # default: git diff HEAD -# codereview.sh origin/main...HEAD # a pre-push style range -# -# Environment: -# CODEREVIEW_ENGINE docker-agent | claude | codex | copilot -# (default: first of those found on PATH) -# CODEREVIEW_ENGINE_CMD full command run via sh -c, prompt on stdin; -# overrides CODEREVIEW_ENGINE entirely -# CODEREVIEW_ENGINE_FLAGS safety flags for docker-agent (default: -# --safety restricted) -# CODEREVIEW_AGENT_CONFIG docker-agent config path (default: -# review-agent.yaml next to this script) -# CODEREVIEW_RUBRIC rubric path (default: review-rubric.md next to -# this script, then the plugin skill copy) -# CODEREVIEW_MAX_TURNS turn cap for CLI engines (default 25) -# CODEREVIEW_DIFF_FILE read the diff from a file instead of running git -# CODEREVIEW_BLOCKING "1" exits 1 when a blocker is found (for hooks -# that gate; default advisory) -# -# Exit: 0 review printed (or empty diff), 1 engine/contract failure or a -# blocker under CODEREVIEW_BLOCKING=1, 2 configuration error. -# ============================================================================= - -set -u - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) - -err() { printf 'codereview: %s\n' "$1" >&2; } - -# --- locate the rubric ------------------------------------------------------- - -RUBRIC="${CODEREVIEW_RUBRIC:-}" -if [ -z "$RUBRIC" ]; then - for candidate in \ - "$SCRIPT_DIR/review-rubric.md" \ - "$SCRIPT_DIR/../skills/mr-review-agent/references/review-rubric.md"; do - if [ -f "$candidate" ]; then RUBRIC="$candidate"; break; fi - done -fi -if [ -z "$RUBRIC" ] || [ ! -f "$RUBRIC" ]; then - err "rubric not found; set CODEREVIEW_RUBRIC" - exit 2 -fi - -# --- collect the diff -------------------------------------------------------- - -WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/codereview.XXXXXX") || exit 2 -cleanup() { rm -rf "$WORKDIR"; } -trap cleanup EXIT - -DIFF_FILE="$WORKDIR/diff.patch" -if [ -n "${CODEREVIEW_DIFF_FILE:-}" ]; then - cp "$CODEREVIEW_DIFF_FILE" "$DIFF_FILE" || exit 2 -elif [ $# -gt 0 ]; then - git diff --no-color "$@" >"$DIFF_FILE" || { err "git diff failed"; exit 2; } -else - git diff --no-color HEAD >"$DIFF_FILE" || { err "git diff failed"; exit 2; } -fi - -if [ ! -s "$DIFF_FILE" ]; then - echo "codereview: empty diff, nothing to review." - exit 0 -fi - -# --- build the prompt -------------------------------------------------------- - -PROMPT_FILE="$WORKDIR/prompt.md" -{ - cat "$RUBRIC" - printf '\n\nRespond with ONLY the findings-contract JSON object. No prose before or after it.\n' - printf '\nDiff under review:\n```diff\n' - cat "$DIFF_FILE" - printf '```\n' -} >"$PROMPT_FILE" - -# --- pick and run the engine ------------------------------------------------- -# The engine reviews untrusted diff content; run it with no GitLab credentials -# in its environment. - -OUT_FILE="$WORKDIR/engine-output.txt" -MAX_TURNS="${CODEREVIEW_MAX_TURNS:-25}" - -# Set by run_engine on OUR configuration errors, so an engine that happens to -# exit 2 is not mistaken for one. -CONFIG_ERR=0 - -run_engine() { - if [ -n "${CODEREVIEW_ENGINE_CMD:-}" ]; then - GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= sh -c "$CODEREVIEW_ENGINE_CMD" <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" - return $? - fi - - engine="${CODEREVIEW_ENGINE:-}" - if [ -z "$engine" ]; then - for candidate in docker-agent claude codex copilot; do - if command -v "$candidate" >/dev/null 2>&1; then engine="$candidate"; break; fi - done - fi - if [ -z "$engine" ]; then - err "no engine found (docker-agent, claude, codex or copilot); set CODEREVIEW_ENGINE_CMD" - CONFIG_ERR=1 - return 2 - fi - - case "$engine" in - docker-agent) - config="${CODEREVIEW_AGENT_CONFIG:-$SCRIPT_DIR/review-agent.yaml}" - [ -f "$config" ] || config="$SCRIPT_DIR/../skills/mr-review-agent/examples/review-agent.yaml" - # shellcheck disable=SC2086 -- flags are deliberately word-split - GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= TELEMETRY_ENABLED=false \ - docker-agent run --exec "$config" --json ${CODEREVIEW_ENGINE_FLAGS:---safety restricted} - \ - <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" - ;; - claude) - GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ - claude -p --output-format json --max-turns "$MAX_TURNS" --allowedTools "Read Grep Glob" \ - <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" - ;; - codex) - # Best effort: verify flags against `codex exec --help` for the - # installed version; override with CODEREVIEW_ENGINE_CMD on drift. - GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ - codex exec --json <"$PROMPT_FILE" >"$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" - ;; - copilot) - # Best effort: verify flags against `copilot --help` for the installed - # version; override with CODEREVIEW_ENGINE_CMD on drift. The prompt rides - # argv (no stdin mode), which the OS caps at 128 KiB per element. - if [ "$(wc -c <"$PROMPT_FILE")" -gt 120000 ]; then - err "prompt too large for the copilot engine's argument passing; lower the diff size or use CODEREVIEW_ENGINE_CMD with a stdin-reading command" - CONFIG_ERR=1 - return 2 - fi - GITLAB_TOKEN= GITLAB_ACCESS_TOKEN= CI_JOB_TOKEN= \ - copilot -p "$(cat "$PROMPT_FILE")" "$OUT_FILE" 2>"$WORKDIR/engine-stderr.txt" - ;; - *) - err "unknown CODEREVIEW_ENGINE \"$engine\"" - CONFIG_ERR=1 - return 2 - ;; - esac -} - -run_engine -engine_status=$? -if [ "$CONFIG_ERR" -eq 1 ]; then - exit 2 -fi -if [ ! -s "$OUT_FILE" ]; then - err "engine produced no output (exit $engine_status)" - [ -s "$WORKDIR/engine-stderr.txt" ] && cat "$WORKDIR/engine-stderr.txt" >&2 - exit 1 -fi - -# --- report ------------------------------------------------------------------ -# Prefer the wrapper's parser (same contract as CI); fall back to a raw dump -# plus a grep when bun is not installed. - -EXTRACTOR="$SCRIPT_DIR/post-mr-review.ts" -if command -v bun >/dev/null 2>&1 && [ -f "$EXTRACTOR" ]; then - if [ "${CODEREVIEW_BLOCKING:-0}" = "1" ]; then - bun "$EXTRACTOR" --extract "$OUT_FILE" --report --blocking - else - bun "$EXTRACTOR" --extract "$OUT_FILE" --report - fi - exit $? -fi - -cat "$OUT_FILE" -if [ "${CODEREVIEW_BLOCKING:-0}" = "1" ] && - grep -Eq '"severity"[[:space:]]*:[[:space:]]*"blocker"' "$OUT_FILE"; then - err "blocker finding present" - exit 1 -fi -exit 0 diff --git a/plugins/code-reviews/scripts/post-mr-review.ts b/plugins/code-reviews/scripts/post-mr-review.ts deleted file mode 100644 index 5e3b50d..0000000 --- a/plugins/code-reviews/scripts/post-mr-review.ts +++ /dev/null @@ -1,796 +0,0 @@ -#!/usr/bin/env bun -/** - * post-mr-review -- run an AI review engine over a merge request and deliver - * the findings to GitLab. - * - * Zero dependencies, node: builtins only, run under bun. - * - * Designed to run inside a GitLab CI merge-request pipeline, copied into the - * target repository (CI cannot resolve plugin paths). The engine produces - * findings; this script does everything with side effects: it builds the - * prompt, spawns the engine, validates the output against the findings - * contract (see review-rubric.md), and posts the review. - * - * Modes (CODEREVIEW_MODE, default "inline"): - * inline one positioned discussion per finding + a sticky summary note; - * findings whose position GitLab rejects degrade to plain notes - * summary a single sticky summary note - * log job log + artifacts only; the automatic fallback when no - * GITLAB_TOKEN is set, because CI_JOB_TOKEN cannot create MR notes - * - * Engines (CODEREVIEW_ENGINE, default "docker-agent"): - * docker-agent | claude | codex | copilot | custom via CODEREVIEW_ENGINE_CMD - * - * The engine subprocess never receives GITLAB_TOKEN: merge-request code is - * untrusted input to the model, and a prompt-injected engine must have nothing - * to exfiltrate and no way to post. - * - * Sticky semantics: every body this script posts carries an HTML marker with - * the reviewed head SHA. On re-push it resolves its own stale discussions, - * posts fresh ones, and updates the summary note in place. A marker matching - * the current head SHA makes the run a no-op, so pipeline retries are free. - * - * Usage: - * bun post-mr-review.ts # normal CI entry point - * bun post-mr-review.ts --extract FILE # parse engine output, print JSON - * [--report] # human-readable report instead - * [--blocking] # exit 1 when blockers found - * - * Exit: 0 review delivered (or nothing to do), 1 engine/contract failure, - * 2 configuration error. Never nonzero for findings alone unless - * --blocking asked for it. - */ - -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; - -// --------------------------------------------------------------------------- -// Findings contract -// --------------------------------------------------------------------------- - -export type Severity = "blocker" | "warning" | "nit"; - -export interface Finding { - path: string; - new_line: number | null; - old_line: number | null; - severity: Severity; - title: string; - body: string; -} - -export interface Review { - summary: string; - findings: Finding[]; -} - -const SEVERITIES: readonly string[] = ["blocker", "warning", "nit"]; - -export function validateReview(value: unknown): { review: Review | null; errors: string[] } { - const errors: string[] = []; - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return { review: null, errors: ["not an object"] }; - } - const obj = value as Record; - if (typeof obj.summary !== "string" || obj.summary.length === 0) { - errors.push("summary: required string"); - } - if (!Array.isArray(obj.findings)) { - errors.push("findings: required array"); - return { review: null, errors }; - } - const findings: Finding[] = []; - obj.findings.forEach((raw, i) => { - if (typeof raw !== "object" || raw === null) { - errors.push(`findings[${i}]: not an object`); - return; - } - const f = raw as Record; - const where = `findings[${i}]`; - if (typeof f.path !== "string" || f.path.length === 0) errors.push(`${where}.path: required string`); - if (typeof f.title !== "string" || f.title.length === 0) errors.push(`${where}.title: required string`); - if (typeof f.body !== "string" || f.body.length === 0) errors.push(`${where}.body: required string`); - if (!SEVERITIES.includes(f.severity as string)) errors.push(`${where}.severity: must be blocker|warning|nit`); - const newLine = f.new_line === undefined || f.new_line === null ? null : f.new_line; - const oldLine = f.old_line === undefined || f.old_line === null ? null : f.old_line; - if (newLine !== null && (typeof newLine !== "number" || !Number.isInteger(newLine) || newLine < 1)) { - errors.push(`${where}.new_line: must be a positive integer or null`); - } - if (oldLine !== null && (typeof oldLine !== "number" || !Number.isInteger(oldLine) || oldLine < 1)) { - errors.push(`${where}.old_line: must be a positive integer or null`); - } - if (newLine === null && oldLine === null) { - errors.push(`${where}: needs new_line (or old_line for a deletion)`); - } - findings.push({ - path: String(f.path), - new_line: newLine as number | null, - old_line: oldLine as number | null, - severity: f.severity as Severity, - title: String(f.title), - body: String(f.body), - }); - }); - if (errors.length > 0) return { review: null, errors }; - return { review: { summary: String(obj.summary), findings }, errors: [] }; -} - -/** - * Pull a Review out of whatever an engine printed. - * - * Engines differ: docker-agent --json emits newline-delimited events, claude - * --output-format json wraps its answer in a result object, others print - * prose around a fenced JSON block. Try, in order: the whole text as JSON, - * each ndjson line, fenced ```json blocks, and the outermost brace slice. - * String fields of intermediate objects are searched recursively, and the - * LAST valid candidate wins (later events supersede earlier ones). - */ -export function extractReview(raw: string, depth = 0): Review | null { - if (depth > 4 || raw.length === 0) return null; - let last: Review | null = null; - - const consider = (candidate: unknown): void => { - const direct = validateReview(candidate).review; - if (direct) { - last = direct; - return; - } - if (typeof candidate === "object" && candidate !== null) { - for (const v of Object.values(candidate as Record)) { - if (typeof v === "string" && v.includes("{")) { - const nested = extractReview(v, depth + 1); - if (nested) last = nested; - } else if (typeof v === "object" && v !== null) { - consider(v); - } - } - } - }; - - const tryParse = (text: string): void => { - try { - consider(JSON.parse(text)); - } catch { - /* not JSON; other strategies below */ - } - }; - - tryParse(raw.trim()); - if (last) return last; - - for (const line of raw.split("\n")) { - const t = line.trim(); - if (t.startsWith("{") && t.endsWith("}")) tryParse(t); - } - if (last) return last; - - for (const m of raw.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)) { - tryParse(m[1].trim()); - } - if (last) return last; - - const first = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (first !== -1 && end > first) tryParse(raw.slice(first, end + 1)); - - return last; -} - -// --------------------------------------------------------------------------- -// Mode and engine resolution -// --------------------------------------------------------------------------- - -export type Mode = "inline" | "summary" | "log"; - -export function resolveMode(requested: string | undefined, hasToken: boolean): { mode: Mode; downgraded: boolean } { - const wanted = (requested || "inline").toLowerCase(); - if (wanted !== "inline" && wanted !== "summary" && wanted !== "log") { - // A typo must not silently escalate to the most-privileged posting mode. - throw new Error(`unknown CODEREVIEW_MODE "${requested}" (inline|summary|log)`); - } - const mode = wanted as Mode; - if (mode !== "log" && !hasToken) return { mode: "log", downgraded: true }; - return { mode, downgraded: false }; -} - -export interface EngineSpec { - argv: string[]; - promptVia: "stdin" | "arg"; -} - -/** - * The engine allowlist. docker-agent and claude are the tested pair; codex - * and copilot are best-effort (their headless flags move fast -- verify with - * `codex exec --help` / `copilot --help` and override with - * CODEREVIEW_ENGINE_CMD when they drift). - */ -export function engineSpec(engine: string, env: Record): EngineSpec { - const custom = env.CODEREVIEW_ENGINE_CMD; - if (custom && custom.trim().length > 0) { - // Through a shell, same as codereview.sh, so pipes and quoting behave - // identically on both surfaces. - return { argv: ["sh", "-c", custom.trim()], promptVia: "stdin" }; - } - const maxTurns = env.CODEREVIEW_MAX_TURNS || "25"; - const config = env.CODEREVIEW_AGENT_CONFIG || ".gitlab/codereview/review-agent.yaml"; - const safety = env.CODEREVIEW_ENGINE_FLAGS || "--safety restricted"; - switch (engine) { - case "claude": - return { - argv: ["claude", "-p", "--output-format", "json", "--max-turns", maxTurns, "--allowedTools", "Read Grep Glob"], - promptVia: "stdin", - }; - case "codex": - return { argv: ["codex", "exec", "--json"], promptVia: "stdin" }; - case "copilot": - return { argv: ["copilot", "-p"], promptVia: "arg" }; - case "docker-agent": - return { - argv: ["docker-agent", "run", "--exec", config, "--json", ...safety.split(/\s+/), "-"], - promptVia: "stdin", - }; - default: - throw new Error(`unknown CODEREVIEW_ENGINE "${engine}" (docker-agent|claude|codex|copilot)`); - } -} - -// --------------------------------------------------------------------------- -// Diff handling -// --------------------------------------------------------------------------- - -export interface FileDiff { - old_path: string; - new_path: string; - diff: string; -} - -export function truncateDiff( - files: FileDiff[], - maxFileLines: number, - maxTotalLines: number, -): { text: string; truncated: string[] } { - const truncated: string[] = []; - const parts: string[] = []; - let total = 0; - for (const f of files) { - if (total >= maxTotalLines) { - truncated.push(f.new_path); - continue; - } - const header = `diff --git a/${f.old_path} b/${f.new_path}\n--- a/${f.old_path}\n+++ b/${f.new_path}`; - const lines = f.diff.split("\n"); - let body = f.diff; - if (lines.length > maxFileLines) { - body = lines.slice(0, maxFileLines).join("\n"); - truncated.push(f.new_path); - } - const bodyLines = Math.min(lines.length, maxFileLines); - if (total + bodyLines > maxTotalLines) { - body = lines.slice(0, maxTotalLines - total).join("\n"); - truncated.push(f.new_path); - } - total += body.split("\n").length; - parts.push(`${header}\n${body}`); - } - return { text: parts.join("\n"), truncated: [...new Set(truncated)] }; -} - -export function buildPrompt( - rubric: string, - mr: { title: string; description: string }, - diffText: string, - truncated: string[], -): string { - const note = - truncated.length > 0 - ? `\nNote: the following files were truncated or omitted for size; say so in the summary: ${truncated.join(", ")}\n` - : ""; - return [ - rubric.trim(), - "", - "Respond with ONLY the findings-contract JSON object. No prose before or after it.", - note, - `Merge request title: ${mr.title}`, - `Merge request description:\n${mr.description || "(none)"}`, - "", - "Diff under review:", - "```diff", - diffText, - "```", - ].join("\n"); -} - -// --------------------------------------------------------------------------- -// Rendering and sticky markers -// --------------------------------------------------------------------------- - -// The kind matters: summary notes and per-finding fallback notes both carry a -// marker, and GitLab lists notes newest-first. Matching on sha alone would let -// the newest fallback note be mistaken for the summary and get overwritten. -export type MarkerKind = "summary" | "finding"; - -const MARKER_RE = //; - -export function marker(sha: string, kind: MarkerKind): string { - return ``; -} - -export function markerShaOf(body: string): string | null { - const m = body.match(MARKER_RE); - return m ? m[1] : null; -} - -export function markerKindOf(body: string): string | null { - const m = body.match(MARKER_RE); - return m ? m[2] || null : null; -} - -const SEVERITY_LABEL: Record = { - blocker: "Blocker", - warning: "Warning", - nit: "Nit", -}; - -export function renderFinding(f: Finding, sha: string): string { - return `**[${SEVERITY_LABEL[f.severity]}]** ${f.title}\n\n${f.body}\n\n${marker(sha, "finding")}`; -} - -export function renderFallbackNote(f: Finding, sha: string): string { - const line = f.new_line !== null ? f.new_line : f.old_line; - return `**[${SEVERITY_LABEL[f.severity]}]** \`${f.path}:${line}\` -- ${f.title}\n\n${f.body}\n\n${marker(sha, "finding")}`; -} - -export function renderSummary(review: Review, truncated: string[], sha: string): string { - const counts: Record = { blocker: 0, warning: 0, nit: 0 }; - for (const f of review.findings) counts[f.severity] += 1; - const lines: string[] = ["## Automated code review", ""]; - if (review.findings.length === 0) { - lines.push("No findings. " + review.summary); - } else { - lines.push(review.summary, ""); - lines.push(`| Severity | Count |`, `|---|---|`); - (Object.keys(counts) as Severity[]).forEach((s) => { - if (counts[s] > 0) lines.push(`| ${SEVERITY_LABEL[s]} | ${counts[s]} |`); - }); - lines.push("", "| Finding | Location |", "|---|---|"); - for (const f of review.findings) { - const line = f.new_line !== null ? f.new_line : f.old_line; - lines.push(`| [${SEVERITY_LABEL[f.severity]}] ${f.title.replaceAll("|", "\\|")} | \`${f.path}:${line}\` |`); - } - } - if (truncated.length > 0) { - lines.push("", `Truncated for size and reviewed partially or not at all: ${truncated.map((t) => `\`${t}\``).join(", ")}`); - } - lines.push("", `Reviewed commit ${sha.slice(0, 12)}. Generated review; verify findings before acting on them.`, "", marker(sha, "summary")); - return lines.join("\n"); -} - -export function renderReport(review: Review): string { - const lines: string[] = [review.summary, ""]; - for (const f of review.findings) { - const line = f.new_line !== null ? f.new_line : f.old_line; - lines.push(`[${f.severity.toUpperCase()}] ${f.path}:${line} ${f.title}`); - lines.push(` ${f.body.replaceAll("\n", "\n ")}`, ""); - } - if (review.findings.length === 0) lines.push("No findings."); - return lines.join("\n"); -} - -// --------------------------------------------------------------------------- -// Action planning -// --------------------------------------------------------------------------- - -export interface DiffRefs { - base_sha: string; - head_sha: string; - start_sha: string; -} - -export interface ExistingNote { - id: number; - body: string; -} - -export interface ExistingDiscussion { - id: string; - resolved: boolean; - body: string; -} - -export type Action = - | { type: "resolve_discussion"; discussion_id: string } - | { type: "create_discussion"; body: string; position: Record; fallback_body: string } - | { type: "create_note"; body: string } - | { type: "update_note"; note_id: number; body: string }; - -export function positionFor(f: Finding, refs: DiffRefs): Record { - const position: Record = { - position_type: "text", - base_sha: refs.base_sha, - head_sha: refs.head_sha, - start_sha: refs.start_sha, - new_path: f.path, - old_path: f.path, - }; - if (f.new_line !== null) position.new_line = f.new_line; - if (f.old_line !== null) position.old_line = f.old_line; - return position; -} - -export interface PlanContext { - mode: Mode; - headSha: string; - diffRefs: DiffRefs | null; - truncated: string[]; - existingNotes: ExistingNote[]; - existingDiscussions: ExistingDiscussion[]; -} - -export function planActions(review: Review, ctx: PlanContext): Action[] { - if (ctx.mode === "log") return []; - const actions: Action[] = []; - - if (ctx.mode === "inline") { - for (const d of ctx.existingDiscussions) { - const sha = markerShaOf(d.body); - if (sha !== null && sha !== ctx.headSha && !d.resolved) { - actions.push({ type: "resolve_discussion", discussion_id: d.id }); - } - } - if (ctx.diffRefs) { - for (const f of review.findings) { - actions.push({ - type: "create_discussion", - body: renderFinding(f, ctx.headSha), - position: positionFor(f, ctx.diffRefs), - fallback_body: renderFallbackNote(f, ctx.headSha), - }); - } - } - } - - const summaryBody = renderSummary(review, ctx.truncated, ctx.headSha); - const existing = ctx.existingNotes.find((n) => markerKindOf(n.body) === "summary"); - if (existing) { - actions.push({ type: "update_note", note_id: existing.id, body: summaryBody }); - } else { - actions.push({ type: "create_note", body: summaryBody }); - } - return actions; -} - -// --------------------------------------------------------------------------- -// GitLab API execution (injected for tests) -// --------------------------------------------------------------------------- - -export interface HttpResponse { - status: number; - body: unknown; -} - -export type HttpFn = (method: string, path: string, payload?: unknown) => Promise; - -export async function executeActions(actions: Action[], http: HttpFn): Promise<{ posted: number; fallbacks: number }> { - let posted = 0; - let fallbacks = 0; - for (const a of actions) { - switch (a.type) { - case "resolve_discussion": - await http("PUT", `/discussions/${a.discussion_id}`, { resolved: true }); - break; - case "create_discussion": { - const res = await http("POST", "/discussions", { body: a.body, position: a.position }); - if (res.status >= 200 && res.status < 300) { - posted += 1; - } else if (res.status === 400) { - // GitLab rejects positions it cannot map onto the diff (context - // lines, renames, and similar). Deliver the finding as a plain - // note rather than dropping it. - await http("POST", "/notes", { body: a.fallback_body }); - fallbacks += 1; - } else { - throw new Error(`create_discussion failed with HTTP ${res.status}`); - } - break; - } - case "create_note": - await http("POST", "/notes", { body: a.body }); - break; - case "update_note": - await http("PUT", `/notes/${a.note_id}`, { body: a.body }); - break; - } - } - return { posted, fallbacks }; -} - -function gitlabHttp(env: Record): HttpFn { - const base = `${env.CI_API_V4_URL}/projects/${encodeURIComponent(env.CI_PROJECT_ID as string)}/merge_requests/${env.CI_MERGE_REQUEST_IID}`; - const token = env.GITLAB_TOKEN as string; - return async (method, path, payload) => { - const res = await fetch(`${base}${path}`, { - method, - headers: { "PRIVATE-TOKEN": token, "Content-Type": "application/json" }, - body: payload === undefined ? undefined : JSON.stringify(payload), - }); - let body: unknown = null; - try { - body = await res.json(); - } catch { - body = null; - } - return { status: res.status, body }; - }; -} - -// --------------------------------------------------------------------------- -// CI entry point -// --------------------------------------------------------------------------- - -function fail(message: string, code: 1 | 2): never { - console.error(`post-mr-review: ${message}`); - process.exit(code); -} - -function readJsonFile(path: string): unknown { - return JSON.parse(readFileSync(path, "utf8")); -} - -/** - * The environment an engine subprocess may see. No GitLab token of any kind: - * the engine processes untrusted MR content, and posting is this script's - * job. The rest of the job environment (provider key included) necessarily - * remains reachable. - */ -export function engineChildEnv(env: Record): Record { - const STRIPPED = ["GITLAB_TOKEN", "GITLAB_ACCESS_TOKEN", "CI_JOB_TOKEN"]; - const childEnv: Record = {}; - for (const [k, v] of Object.entries(env)) { - if (v !== undefined && !STRIPPED.includes(k)) childEnv[k] = v; - } - childEnv.TELEMETRY_ENABLED = childEnv.TELEMETRY_ENABLED || "false"; - return childEnv; -} - -// Linux caps a single argv element at 128 KiB (MAX_ARG_STRLEN). Prompts ride -// argv only for engines with no stdin mode; refuse before the kernel does. -const MAX_ARG_PROMPT_BYTES = 120_000; - -function runEngine(spec: EngineSpec, prompt: string, env: Record): string { - const childEnv = engineChildEnv(env); - const argv = [...spec.argv]; - if (spec.promptVia === "arg") { - if (Buffer.byteLength(prompt, "utf8") > MAX_ARG_PROMPT_BYTES) { - fail( - `the prompt (${Buffer.byteLength(prompt, "utf8")} bytes) exceeds the OS argument limit for this ` + - `engine; lower CODEREVIEW_MAX_DIFF_LINES or use CODEREVIEW_ENGINE_CMD with a stdin-reading command`, - 2, - ); - } - argv.push(prompt); - } - const result = spawnSync(argv[0], argv.slice(1), { - input: spec.promptVia === "stdin" ? prompt : undefined, - env: childEnv, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - if (result.error) fail(`could not run engine "${argv[0]}": ${result.error.message}`, 1); - const out = `${result.stdout || ""}\n${result.stderr || ""}`; - if (typeof result.status === "number" && result.status !== 0 && !extractReview(result.stdout || "")) { - console.error(out); - fail(`engine exited ${result.status} without usable output`, 1); - } - return result.stdout || ""; -} - -function localDiff(baseSha: string): FileDiff[] { - const result = spawnSync("git", ["diff", "--no-color", `${baseSha}...HEAD`], { - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - if (result.status !== 0) { - fail(`git diff against ${baseSha} failed (shallow clone? set GIT_DEPTH: "0"): ${result.stderr}`, 2); - } - return parseGitDiff(result.stdout || ""); -} - -/** - * Split a raw `git diff` back into per-file chunks so truncation budgets - * apply per file, same as the API path. The body starts at the first hunk: - * truncateDiff() adds its own header lines, so keeping git's index/---/+++ - * metadata would duplicate them in the prompt. Paths that git quotes - * (spaces, non-ASCII) do not match and are skipped -- a documented - * limitation of tokenless mode. - */ -export function parseGitDiff(raw: string): FileDiff[] { - const files: FileDiff[] = []; - for (const chunk of raw.split(/^diff --git /m).slice(1)) { - const m = chunk.match(/^a\/(\S+) b\/(\S+)/); - if (!m) continue; - const lines = chunk.split("\n").slice(1); - const hunkStart = lines.findIndex((l) => l.startsWith("@@")); - const body = (hunkStart === -1 ? lines : lines.slice(hunkStart)).join("\n"); - files.push({ old_path: m[1], new_path: m[2], diff: body }); - } - return files; -} - -async function main(): Promise { - const env = process.env; - const dryRun = env.CODEREVIEW_DRY_RUN === "1"; - const artifactsDir = env.CODEREVIEW_ARTIFACTS || "codereview-artifacts"; - const hasToken = Boolean(env.GITLAB_TOKEN); - let resolved: { mode: Mode; downgraded: boolean }; - try { - resolved = resolveMode(env.CODEREVIEW_MODE, hasToken); - } catch (err) { - fail(err instanceof Error ? err.message : String(err), 2); - } - const { mode, downgraded } = resolved; - if (downgraded) { - console.error( - `post-mr-review: CODEREVIEW_MODE=${env.CODEREVIEW_MODE || "inline"} requires GITLAB_TOKEN ` + - `(CI_JOB_TOKEN cannot create MR notes); falling back to log mode.`, - ); - } - - const iid = env.CI_MERGE_REQUEST_IID; - if (!iid && !dryRun) fail("not a merge request pipeline (CI_MERGE_REQUEST_IID unset)", 2); - - const headSha = env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA || env.CI_COMMIT_SHA || "unknown"; - const http: HttpFn = dryRun - ? async (method, path, payload) => { - console.log(JSON.stringify({ planned: { method, path, payload } })); - return { status: 200, body: null }; - } - : gitlabHttp(env); - - // Gather MR state (token modes only). - let diffRefs: DiffRefs | null = null; - let files: FileDiff[] = []; - let mrTitle = env.CI_MERGE_REQUEST_TITLE || ""; - let mrDescription = env.CI_MERGE_REQUEST_DESCRIPTION || ""; - let existingNotes: ExistingNote[] = []; - let existingDiscussions: ExistingDiscussion[] = []; - - if (mode !== "log") { - const mrRaw = dryRun && env.CODEREVIEW_FIXTURE_CHANGES - ? readJsonFile(env.CODEREVIEW_FIXTURE_CHANGES) - : (await http("GET", "/changes")).body; - const mr = (mrRaw || {}) as { - title?: string; - description?: string; - draft?: boolean; - work_in_progress?: boolean; - diff_refs?: DiffRefs; - changes?: FileDiff[]; - }; - if (mr.draft === true || mr.work_in_progress === true) { - console.log("post-mr-review: draft merge request, skipping review."); - return; - } - mrTitle = mr.title || mrTitle; - mrDescription = mr.description || mrDescription; - diffRefs = mr.diff_refs || null; - files = mr.changes || []; - - const notesRaw = dryRun && env.CODEREVIEW_FIXTURE_NOTES - ? readJsonFile(env.CODEREVIEW_FIXTURE_NOTES) - : (await http("GET", "/notes?per_page=100")).body; - existingNotes = ((notesRaw as { id: number; body: string }[]) || []).map((n) => ({ id: n.id, body: n.body })); - - const discussionsRaw = dryRun && env.CODEREVIEW_FIXTURE_DISCUSSIONS - ? readJsonFile(env.CODEREVIEW_FIXTURE_DISCUSSIONS) - : (await http("GET", "/discussions?per_page=100")).body; - existingDiscussions = ((discussionsRaw as { id: string; notes?: { body: string; resolved?: boolean }[] }[]) || []) - .filter((d) => (d.notes || []).length > 0) - .map((d) => ({ - id: d.id, - resolved: Boolean(d.notes && d.notes[0].resolved), - body: d.notes && d.notes[0] ? d.notes[0].body : "", - })); - - // Same head already reviewed: a pipeline retry, not a new push. - const summaryNote = existingNotes.find((n) => markerKindOf(n.body) === "summary"); - if (summaryNote && markerShaOf(summaryNote.body) === headSha) { - console.log(`post-mr-review: head ${headSha.slice(0, 12)} already reviewed, nothing to do.`); - return; - } - } else { - const baseSha = env.CI_MERGE_REQUEST_DIFF_BASE_SHA; - if (!baseSha && !dryRun) fail("log mode needs CI_MERGE_REQUEST_DIFF_BASE_SHA for a local diff", 2); - if (baseSha) files = localDiff(baseSha); - } - - const maxFileLines = Number(env.CODEREVIEW_MAX_FILE_LINES || 1500); - const maxTotalLines = Number(env.CODEREVIEW_MAX_DIFF_LINES || 6000); - const { text: diffText, truncated } = truncateDiff(files, maxFileLines, maxTotalLines); - if (diffText.trim().length === 0 && !env.CODEREVIEW_FIXTURE_OUTPUT) { - console.log("post-mr-review: empty diff, nothing to review."); - return; - } - - const rubricPath = env.CODEREVIEW_RUBRIC || ".gitlab/codereview/review-rubric.md"; - let rubric = ""; - try { - rubric = readFileSync(rubricPath, "utf8"); - } catch { - // A saved engine output makes the prompt (and so the rubric) unused. - if (!env.CODEREVIEW_FIXTURE_OUTPUT) fail(`rubric not found at ${rubricPath}`, 2); - } - const prompt = buildPrompt(rubric, { title: mrTitle, description: mrDescription }, diffText, truncated); - - mkdirSync(artifactsDir, { recursive: true }); - let rawOutput: string; - if (env.CODEREVIEW_FIXTURE_OUTPUT) { - rawOutput = readFileSync(env.CODEREVIEW_FIXTURE_OUTPUT, "utf8"); - } else { - const engine = env.CODEREVIEW_ENGINE || "docker-agent"; - let spec: EngineSpec; - try { - spec = engineSpec(engine, env); - } catch (err) { - // A typo'd engine is a configuration error, same as a typo'd mode. - fail(err instanceof Error ? err.message : String(err), 2); - } - rawOutput = runEngine(spec, prompt, env); - } - writeFileSync(join(artifactsDir, "transcript.ndjson"), rawOutput); - - const review = extractReview(rawOutput); - if (!review) { - fail("engine output did not contain a valid findings-contract object (transcript saved to artifacts)", 1); - } - writeFileSync(join(artifactsDir, "findings.json"), JSON.stringify(review, null, 2)); - writeFileSync(join(artifactsDir, "review.md"), renderSummary(review, truncated, headSha)); - - if (mode === "log") { - console.log(renderReport(review)); - return; - } - - const actions = planActions(review, { - mode, - headSha, - diffRefs, - truncated, - existingNotes, - existingDiscussions, - }); - const { posted, fallbacks } = await executeActions(actions, http); - console.log( - `post-mr-review: delivered ${review.findings.length} finding(s) in ${mode} mode` + - (mode === "inline" ? ` (${posted} positioned, ${fallbacks} as plain notes)` : "") + - ".", - ); -} - -// --------------------------------------------------------------------------- -// --extract: parse a saved engine output file (used by codereview.sh) -// --------------------------------------------------------------------------- - -function extractCli(args: string[]): void { - const file = args.find((a) => !a.startsWith("--")); - if (!file) fail("--extract needs a file argument", 2); - const review = extractReview(readFileSync(file, "utf8")); - if (!review) fail("no valid findings-contract object in engine output", 1); - if (args.includes("--report")) { - console.log(renderReport(review)); - } else { - console.log(JSON.stringify(review, null, 2)); - } - if (args.includes("--blocking") && review.findings.some((f) => f.severity === "blocker")) { - process.exit(1); - } -} - -if (import.meta.main) { - const args = process.argv.slice(2); - if (args[0] === "--extract") { - extractCli(args.slice(1)); - } else { - main().catch((err) => fail(err instanceof Error ? err.message : String(err), 1)); - } -} diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/claude-output.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/claude-output.json deleted file mode 100644 index 2b8a8df..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/claude-output.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "result", - "subtype": "success", - "is_error": false, - "duration_ms": 48210, - "num_turns": 6, - "result": "{\"summary\":\"One concurrency defect in the retry queue; everything else in the diff is sound.\",\"findings\":[{\"path\":\"src/queue/retry.ts\",\"new_line\":88,\"old_line\":null,\"severity\":\"warning\",\"title\":\"Retry loop has no upper bound\",\"body\":\"`while (!done)` re-enqueues on every failure with no attempt counter or backoff. A permanently failing job spins forever. Bound the attempts and surface the terminal failure.\"}]}", - "session_id": "3f1c2a44-example" -} diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json deleted file mode 100644 index 1a88ee8..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/discussions-stale.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - { - "id": "d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0", - "notes": [ - { - "body": "**[Warning]** Stale finding from the previous revision\n\nDetail.\n\n", - "resolved": false - } - ] - }, - { - "id": "e2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", - "notes": [ - { - "body": "Human-opened thread about naming.", - "resolved": false - } - ] - } -] diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes-draft.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes-draft.json deleted file mode 100644 index 4d65c65..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes-draft.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "iid": 8, - "title": "Draft: rework the charge path", - "description": "Not ready for review.", - "draft": true, - "work_in_progress": true, - "diff_refs": { - "base_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", - "head_sha": "beefbeefbeefbeefbeefbeefbeefbeefbeefbeef", - "start_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" - }, - "changes": [ - { - "old_path": "src/payments/charge.ts", - "new_path": "src/payments/charge.ts", - "diff": "@@ -1 +1 @@\n+wip\n" - } - ] -} diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes.json deleted file mode 100644 index fee2ddd..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/mr-changes.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "iid": 7, - "title": "Add currency handling to the charge path", - "description": "Extends charge() to accept a currency code and drops the legacy refund guard.", - "draft": false, - "work_in_progress": false, - "diff_refs": { - "base_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", - "head_sha": "beefbeefbeefbeefbeefbeefbeefbeefbeefbeef", - "start_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" - }, - "changes": [ - { - "old_path": "src/payments/charge.ts", - "new_path": "src/payments/charge.ts", - "diff": "@@ -38,6 +38,10 @@ export async function charge(req: Request) {\n const amount = req.body.amount;\n+ const currency = req.body.currency;\n+ const entry = { amount, currency };\n+ await ledger.post(entry);\n+ return entry;\n }" - }, - { - "old_path": "src/payments/refund.ts", - "new_path": "src/payments/refund.ts", - "diff": "@@ -14,8 +14,6 @@ export async function refund(id: string) {\n const invoice = await invoices.get(id);\n- if (await ledger.seen(idempotencyKey(id))) {\n- return ledger.lastResult(id);\n- }\n return ledger.credit(invoice);\n }" - } - ] -} diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json deleted file mode 100644 index c509f27..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-with-marker.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "id": 60, - "body": "**[Warning]** `src/payments/refund.ts:17` -- Fallback finding from the previous revision\n\nPosted as a plain note because GitLab rejected its diff position.\n\n" - }, - { - "id": 55, - "body": "Looks reasonable overall, one question about the ledger call inline." - }, - { - "id": 101, - "body": "## Automated code review\n\nPrevious review of an earlier revision.\n\nReviewed commit abc123abc123.\n\n" - } -] diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-without-marker.json b/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-without-marker.json deleted file mode 100644 index 9d4d82c..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/notes-without-marker.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "id": 55, - "body": "Looks reasonable overall, one question about the ledger call inline." - } -] diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-engine.sh b/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-engine.sh deleted file mode 100644 index ea7f07d..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-engine.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -# A fake review engine for harness tests: consumes the prompt on stdin and -# prints a findings-contract object containing one blocker. -cat >/dev/null -printf '%s\n' '{"summary":"Stub engine review.","findings":[{"path":"src/app.ts","new_line":10,"old_line":null,"severity":"blocker","title":"Stub blocker finding","body":"Emitted by the test stub."}]}' diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-env-probe.sh b/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-env-probe.sh deleted file mode 100644 index 2b7304d..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/stub-env-probe.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -# A fake review engine that reports its own environment: whether any GitLab -# token is visible (they must all be stripped or blank) and whether an -# unrelated control variable survived (it must). The summary encodes both, so -# the harness test asserts on the rendered report. -cat >/dev/null - -leak="clean" -if [ -n "${GITLAB_TOKEN:-}" ] || [ -n "${GITLAB_ACCESS_TOKEN:-}" ] || [ -n "${CI_JOB_TOKEN:-}" ]; then - leak="token-leaked" -fi - -control="control-missing" -if [ "${PROBE_CONTROL:-}" = "present" ]; then - control="control-ok" -fi - -printf '{"summary":"env probe: %s %s","findings":[]}\n' "$leak" "$control" diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson b/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson deleted file mode 100644 index 3b8ca34..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript-malformed.ndjson +++ /dev/null @@ -1,4 +0,0 @@ -{"type":"session_start","agent":"root","model":"anthropic/claude-opus-5"} -{"type":"message","role":"assistant","content":"Here is my review of the diff: the charge path has a validation gap around line 42."} -{"type":"structured_output","content":"{\"summary\":\"Truncated before the findings array closed\",\"findings\":[{\"path\":\"src/payments/charge.ts\",\"new_line\":42"} -{"type":"error","message":"model stream ended unexpectedly"} diff --git a/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript.ndjson b/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript.ndjson deleted file mode 100644 index 57cff3b..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/fixtures/transcript.ndjson +++ /dev/null @@ -1,6 +0,0 @@ -{"type":"session_start","agent":"root","model":"anthropic/claude-opus-5"} -{"type":"message","role":"assistant","content":"Reading the changed payment paths before judging the diff."} -{"type":"tool_call","name":"read_file","arguments":{"path":"src/payments/charge.ts"}} -{"type":"tool_result","name":"read_file","status":"ok"} -{"type":"structured_output","content":"{\"summary\":\"Two payment-path defects; the schema migration itself looks safe.\",\"findings\":[{\"path\":\"src/payments/charge.ts\",\"new_line\":42,\"old_line\":null,\"severity\":\"blocker\",\"title\":\"Charge amount accepted without currency validation\",\"body\":\"`charge()` trusts `req.body.currency`; an unknown code is passed to the ledger and recorded as USD. Validate against SUPPORTED_CURRENCIES before posting.\"},{\"path\":\"src/payments/refund.ts\",\"new_line\":null,\"old_line\":17,\"severity\":\"warning\",\"title\":\"Idempotency check removed with no replacement\",\"body\":\"The deleted guard was the only duplicate-refund protection; retries now double-refund. Restore the idempotency key check or dedupe at the ledger.\"}]}"} -{"type":"session_end","turns":4} diff --git a/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh b/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh deleted file mode 100644 index 8dfa24a..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/run-tests.sh +++ /dev/null @@ -1,250 +0,0 @@ -#!/bin/sh -# ============================================================================= -# Tests for post-mr-review.ts and codereview.sh. -# -# Fixture-driven throughout: no network, no GitLab, no real engines. The -# wrapper's pure functions are covered by unit.test.ts under `bun test`; this -# suite covers the CLI contract, the dry-run action planning, and the shell -# harness with a stub engine. scripts/verify-all.sh discovers and runs this -# file, so a regression fails the repository gate. -# ============================================================================= - -set -u - -SUITE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -PLUGIN_DIR=$(CDPATH= cd -- "$SUITE_DIR/../../.." && pwd) -WRAPPER="$PLUGIN_DIR/scripts/post-mr-review.ts" -HARNESS="$PLUGIN_DIR/scripts/codereview.sh" -FIXTURES="$SUITE_DIR/fixtures" - -passed=0 -failed=0 - -pass() { passed=$((passed + 1)); printf ' ok %s\n' "$1"; } -fail() { failed=$((failed + 1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; } - -echo "post-mr-review.ts / codereview.sh" - -for f in "$WRAPPER" "$HARNESS"; do - if [ ! -f "$f" ]; then - fail "exists: $f" - echo " $passed passed, $failed failed" - exit 1 - fi -done -pass "wrapper and harness exist" - -if ! command -v bun >/dev/null 2>&1; then - echo " note: bun not installed; skipping execution tests" - echo " $passed passed, $failed failed" - [ "$failed" -eq 0 ] || exit 1 - exit 0 -fi - -TMPDIR_LOCAL="$PLUGIN_DIR/../../.tmp" -mkdir -p "$TMPDIR_LOCAL" -WORK=$(mktemp -d "$TMPDIR_LOCAL/mr-review-tests.XXXXXX") || exit 1 -cleanup() { rm -rf "$WORK"; } -trap cleanup EXIT - -# --- unit tests --------------------------------------------------------------- - -if bun test "$SUITE_DIR/unit.test.ts" >"$WORK/unit.log" 2>&1; then - pass "unit.test.ts suite" -else - fail "unit.test.ts suite" "$(tail -5 "$WORK/unit.log")" -fi - -# --- --extract contract -------------------------------------------------------- - -out=$(bun "$WRAPPER" --extract "$FIXTURES/transcript.ndjson" 2>&1) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Charge amount accepted without currency validation'; then - pass "--extract parses a docker-agent transcript" -else - fail "--extract parses a docker-agent transcript" "$out" -fi - -out=$(bun "$WRAPPER" --extract "$FIXTURES/claude-output.json" --report 2>&1) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Retry loop has no upper bound'; then - pass "--extract --report renders a claude result" -else - fail "--extract --report renders a claude result" "$out" -fi - -if bun "$WRAPPER" --extract "$FIXTURES/transcript.ndjson" --report --blocking >/dev/null 2>&1; then - fail "--blocking exits 1 on a blocker" -else - pass "--blocking exits 1 on a blocker" -fi - -if bun "$WRAPPER" --extract "$FIXTURES/claude-output.json" --blocking >/dev/null 2>&1; then - pass "--blocking exits 0 with no blocker" -else - fail "--blocking exits 0 with no blocker" -fi - -out=$(bun "$WRAPPER" --extract "$FIXTURES/transcript-malformed.ndjson" 2>&1) -if [ $? -ne 0 ] && printf '%s' "$out" | grep -q 'no valid findings-contract object'; then - pass "malformed transcript exits non-zero with the contract error" -else - fail "malformed transcript exits non-zero with the contract error" "$out" -fi - -# --- dry-run: inline mode over fixtures ---------------------------------------- - -run_dry() { - # $1 head sha, $2 mode, $3 token ("" for none), remaining env via caller - CODEREVIEW_DRY_RUN=1 \ - GITLAB_TOKEN="$3" \ - CODEREVIEW_MODE="$2" \ - CI_API_V4_URL="https://gitlab.example.com/api/v4" \ - CI_PROJECT_ID="123" \ - CI_MERGE_REQUEST_IID="7" \ - CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="$1" \ - CODEREVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes.json" \ - CODEREVIEW_FIXTURE_NOTES="$FIXTURES/notes-with-marker.json" \ - CODEREVIEW_FIXTURE_DISCUSSIONS="$FIXTURES/discussions-stale.json" \ - CODEREVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ - CODEREVIEW_ARTIFACTS="$WORK/artifacts" \ - bun "$WRAPPER" 2>"$WORK/dry-stderr.txt" -} - -HEAD_SHA="beefbeefbeefbeefbeefbeefbeefbeefbeefbeef" - -out=$(run_dry "$HEAD_SHA" inline fake-token) -status=$? - -if [ "$status" -eq 0 ]; then - pass "dry-run inline exits 0" -else - fail "dry-run inline exits 0" "exit $status: $(cat "$WORK/dry-stderr.txt")" -fi - -count=$(printf '%s\n' "$out" | grep -c '"path":"/discussions"') -if [ "$count" -eq 2 ]; then - pass "dry-run inline plans one discussion per finding" -else - fail "dry-run inline plans one discussion per finding" "saw $count: $out" -fi - -if printf '%s' "$out" | grep -q '"path":"/discussions/d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0"'; then - pass "dry-run inline resolves the stale bot thread" -else - fail "dry-run inline resolves the stale bot thread" "$out" -fi - -if printf '%s' "$out" | grep -q '"path":"/notes/101"'; then - pass "dry-run inline updates the sticky summary note" -else - fail "dry-run inline updates the sticky summary note" "$out" -fi - -if printf '%s' "$out" | grep -q '"new_line":42'; then - pass "dry-run inline carries the diff position" -else - fail "dry-run inline carries the diff position" "$out" -fi - -if [ -f "$WORK/artifacts/findings.json" ] && [ -f "$WORK/artifacts/review.md" ]; then - pass "dry-run writes findings.json and review.md artifacts" -else - fail "dry-run writes findings.json and review.md artifacts" -fi - -# --- dry-run: draft MRs are skipped server-side --------------------------------- - -out=$(CODEREVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token CODEREVIEW_MODE=inline \ - CI_API_V4_URL="https://gitlab.example.com/api/v4" CI_PROJECT_ID="123" CI_MERGE_REQUEST_IID="8" \ - CI_MERGE_REQUEST_SOURCE_BRANCH_SHA="$HEAD_SHA" \ - CODEREVIEW_FIXTURE_CHANGES="$FIXTURES/mr-changes-draft.json" \ - CODEREVIEW_FIXTURE_OUTPUT="$FIXTURES/transcript.ndjson" \ - CODEREVIEW_ARTIFACTS="$WORK/artifacts-draft" \ - bun "$WRAPPER" 2>&1) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'draft merge request' && - ! printf '%s' "$out" | grep -q '"planned"'; then - pass "draft MR skips the review and plans no actions" -else - fail "draft MR skips the review and plans no actions" "$out" -fi - -# --- configuration typos exit 2, not 1 ------------------------------------------ - -CODEREVIEW_DRY_RUN=1 GITLAB_TOKEN=fake-token CODEREVIEW_MODE=inlien \ - CI_MERGE_REQUEST_IID="7" bun "$WRAPPER" >/dev/null 2>&1 -status=$? -if [ "$status" -eq 2 ]; then - pass "unknown CODEREVIEW_MODE exits 2" -else - fail "unknown CODEREVIEW_MODE exits 2" "exit $status" -fi - -# --- dry-run: pipeline retry is a no-op ---------------------------------------- - -out=$(run_dry "abc123abc123" inline fake-token) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'already reviewed'; then - pass "same-head retry is a no-op" -else - fail "same-head retry is a no-op" "$out" -fi - -# --- dry-run: no token downgrades to log mode ---------------------------------- - -out=$(run_dry "$HEAD_SHA" inline "") -if [ $? -eq 0 ] && - grep -q 'falling back to log mode' "$WORK/dry-stderr.txt" && - printf '%s' "$out" | grep -q '\[BLOCKER\]'; then - pass "missing token downgrades inline to log with a notice" -else - fail "missing token downgrades inline to log with a notice" "$out $(cat "$WORK/dry-stderr.txt")" -fi - -# --- harness: engines never see GitLab tokens ------------------------------------ - -printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n+probe\n' >"$WORK/probe.diff" - -out=$(TMPDIR="$WORK" \ - GITLAB_TOKEN=super-secret GITLAB_ACCESS_TOKEN=also-secret CI_JOB_TOKEN=job-secret \ - PROBE_CONTROL=present \ - CODEREVIEW_DIFF_FILE="$WORK/probe.diff" \ - CODEREVIEW_ENGINE_CMD="sh $FIXTURES/stub-env-probe.sh" \ - sh "$HARNESS" 2>&1) -if printf '%s' "$out" | grep -q 'env probe: clean control-ok'; then - pass "harness strips GitLab tokens but not the rest of the env" -else - fail "harness strips GitLab tokens but not the rest of the env" "$out" -fi - -# --- harness: stub engine ------------------------------------------------------- - -printf 'diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n+changed\n' >"$WORK/stub.diff" - -out=$(TMPDIR="$WORK" \ - CODEREVIEW_DIFF_FILE="$WORK/stub.diff" \ - CODEREVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ - sh "$HARNESS" 2>&1) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'Stub blocker finding'; then - pass "harness reports stub engine findings" -else - fail "harness reports stub engine findings" "$out" -fi - -if TMPDIR="$WORK" \ - CODEREVIEW_DIFF_FILE="$WORK/stub.diff" \ - CODEREVIEW_ENGINE_CMD="sh $FIXTURES/stub-engine.sh" \ - CODEREVIEW_BLOCKING=1 \ - sh "$HARNESS" >/dev/null 2>&1; then - fail "harness blocking mode exits 1 on a blocker" -else - pass "harness blocking mode exits 1 on a blocker" -fi - -: >"$WORK/empty.diff" -out=$(TMPDIR="$WORK" CODEREVIEW_DIFF_FILE="$WORK/empty.diff" sh "$HARNESS" 2>&1) -if [ $? -eq 0 ] && printf '%s' "$out" | grep -q 'empty diff'; then - pass "harness exits 0 on an empty diff" -else - fail "harness exits 0 on an empty diff" "$out" -fi - -echo " $passed passed, $failed failed" -[ "$failed" -eq 0 ] || exit 1 diff --git a/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts b/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts deleted file mode 100644 index 8e58688..0000000 --- a/plugins/code-reviews/scripts/tests/mr-review/unit.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * Unit tests for post-mr-review.ts pure functions. No network, no engines: - * everything runs against fixtures and injected stubs. run-tests.sh drives - * this file with `bun test` and covers the CLI surface separately. - */ - -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { - type Action, - type DiffRefs, - type Finding, - buildPrompt, - engineChildEnv, - engineSpec, - executeActions, - extractReview, - markerKindOf, - markerShaOf, - parseGitDiff, - planActions, - positionFor, - renderSummary, - resolveMode, - truncateDiff, - validateReview, -} from "../../post-mr-review.ts"; - -const FIXTURES = join(import.meta.dir, "fixtures"); -const fixture = (name: string): string => readFileSync(join(FIXTURES, name), "utf8"); - -const REFS: DiffRefs = { - base_sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", - head_sha: "beefbeefbeefbeefbeefbeefbeefbeefbeefbeef", - start_sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", -}; - -describe("extractReview", () => { - test("parses a bare findings-contract object", () => { - const review = extractReview('{"summary":"ok","findings":[]}'); - expect(review).not.toBeNull(); - expect(review!.findings).toHaveLength(0); - }); - - test("finds structured output nested in a docker-agent ndjson stream", () => { - const review = extractReview(fixture("transcript.ndjson")); - expect(review).not.toBeNull(); - expect(review!.findings).toHaveLength(2); - expect(review!.findings[0].severity).toBe("blocker"); - expect(review!.findings[1].old_line).toBe(17); - }); - - test("unwraps a claude --output-format json result", () => { - const review = extractReview(fixture("claude-output.json")); - expect(review).not.toBeNull(); - expect(review!.findings[0].path).toBe("src/queue/retry.ts"); - }); - - test("reads a fenced json block inside prose", () => { - const raw = 'Here is the review:\n```json\n{"summary":"clean","findings":[]}\n```\nDone.'; - expect(extractReview(raw)).not.toBeNull(); - }); - - test("returns null for a malformed transcript", () => { - expect(extractReview(fixture("transcript-malformed.ndjson"))).toBeNull(); - }); -}); - -describe("validateReview", () => { - test("rejects an unknown severity", () => { - const { review, errors } = validateReview({ - summary: "s", - findings: [{ path: "a.ts", new_line: 1, old_line: null, severity: "critical", title: "t", body: "b" }], - }); - expect(review).toBeNull(); - expect(errors.join(" ")).toContain("severity"); - }); - - test("rejects a finding with neither line", () => { - const { review } = validateReview({ - summary: "s", - findings: [{ path: "a.ts", new_line: null, old_line: null, severity: "nit", title: "t", body: "b" }], - }); - expect(review).toBeNull(); - }); - - test("accepts an empty findings array as a clean review", () => { - expect(validateReview({ summary: "clean", findings: [] }).review).not.toBeNull(); - }); -}); - -describe("resolveMode", () => { - test("keeps inline when a token is present", () => { - expect(resolveMode("inline", true)).toEqual({ mode: "inline", downgraded: false }); - }); - test("downgrades comment modes to log without a token", () => { - expect(resolveMode("inline", false)).toEqual({ mode: "log", downgraded: true }); - expect(resolveMode("summary", false)).toEqual({ mode: "log", downgraded: true }); - }); - test("log mode never needs a token", () => { - expect(resolveMode("log", false)).toEqual({ mode: "log", downgraded: false }); - }); - test("defaults to inline", () => { - expect(resolveMode(undefined, true).mode).toBe("inline"); - }); - test("rejects unknown modes instead of escalating to inline", () => { - expect(() => resolveMode("summry", true)).toThrow("unknown CODEREVIEW_MODE"); - }); -}); - -describe("engineSpec", () => { - test("each named engine produces its documented argv shape", () => { - expect(engineSpec("docker-agent", {}).argv).toEqual([ - "docker-agent", "run", "--exec", ".gitlab/codereview/review-agent.yaml", "--json", "--safety", "restricted", "-", - ]); - expect(engineSpec("claude", {}).argv).toEqual([ - "claude", "-p", "--output-format", "json", "--max-turns", "25", "--allowedTools", "Read Grep Glob", - ]); - expect(engineSpec("codex", {}).argv).toEqual(["codex", "exec", "--json"]); - expect(engineSpec("copilot", {})).toEqual({ argv: ["copilot", "-p"], promptVia: "arg" }); - }); - - test("CODEREVIEW_ENGINE_CMD runs through a shell, matching codereview.sh", () => { - const spec = engineSpec("docker-agent", { CODEREVIEW_ENGINE_CMD: "my-engine --flag 'quoted arg'" }); - expect(spec.argv).toEqual(["sh", "-c", "my-engine --flag 'quoted arg'"]); - expect(spec.promptVia).toBe("stdin"); - }); - - test("a whitespace-only override falls back to the named engine", () => { - expect(engineSpec("codex", { CODEREVIEW_ENGINE_CMD: " " }).argv[0]).toBe("codex"); - }); - - test("an unknown engine throws instead of guessing", () => { - expect(() => engineSpec("gpt", {})).toThrow("unknown CODEREVIEW_ENGINE"); - }); -}); - -describe("engineChildEnv", () => { - test("strips every GitLab token and keeps the rest", () => { - const child = engineChildEnv({ - GITLAB_TOKEN: "secret", - GITLAB_ACCESS_TOKEN: "secret2", - CI_JOB_TOKEN: "secret3", - ANTHROPIC_API_KEY: "provider-key", - CI_PROJECT_ID: "123", - }); - expect(child.GITLAB_TOKEN).toBeUndefined(); - expect(child.GITLAB_ACCESS_TOKEN).toBeUndefined(); - expect(child.CI_JOB_TOKEN).toBeUndefined(); - // Positive controls: stripping must not mean "empty env". - expect(child.ANTHROPIC_API_KEY).toBe("provider-key"); - expect(child.CI_PROJECT_ID).toBe("123"); - }); - - test("telemetry defaults off and an explicit value wins", () => { - expect(engineChildEnv({}).TELEMETRY_ENABLED).toBe("false"); - expect(engineChildEnv({ TELEMETRY_ENABLED: "true" }).TELEMETRY_ENABLED).toBe("true"); - }); -}); - -describe("parseGitDiff", () => { - const raw = [ - "diff --git a/src/app.ts b/src/app.ts", - "index 1111111..2222222 100644", - "--- a/src/app.ts", - "+++ b/src/app.ts", - "@@ -1,2 +1,2 @@", - "-old", - "+new", - 'diff --git "a/with space.ts" "b/with space.ts"', - "@@ -1 +1 @@", - "+x", - ].join("\n"); - - test("bodies start at the first hunk so truncateDiff headers are not duplicated", () => { - const files = parseGitDiff(raw); - expect(files).toHaveLength(1); - expect(files[0].new_path).toBe("src/app.ts"); - expect(files[0].diff.startsWith("@@")).toBe(true); - expect(files[0].diff).not.toContain("+++ b/"); - }); - - test("quoted paths are skipped, not mangled", () => { - expect(parseGitDiff(raw).some((f) => f.new_path.includes("space"))).toBe(false); - }); -}); - -describe("buildPrompt", () => { - test("names truncated files so a partial review cannot pose as a full one", () => { - const prompt = buildPrompt("RUBRIC", { title: "t", description: "d" }, "+x", ["big.ts"]); - expect(prompt).toContain("truncated or omitted for size"); - expect(prompt).toContain("big.ts"); - }); - test("carries no truncation note when nothing was cut", () => { - expect(buildPrompt("RUBRIC", { title: "t", description: "d" }, "+x", [])).not.toContain("truncated or omitted"); - }); -}); - -describe("truncateDiff", () => { - const file = (path: string, lines: number) => ({ - old_path: path, - new_path: path, - diff: Array.from({ length: lines }, (_, i) => `+line ${i}`).join("\n"), - }); - - test("keeps small diffs whole", () => { - const { text, truncated } = truncateDiff([file("a.ts", 10)], 100, 1000); - expect(truncated).toHaveLength(0); - expect(text).toContain("+line 9"); - }); - - test("caps a single oversized file and reports it", () => { - const { text, truncated } = truncateDiff([file("big.ts", 500)], 100, 1000); - expect(truncated).toEqual(["big.ts"]); - expect(text).not.toContain("+line 400"); - }); - - test("drops files past the total budget and reports them", () => { - const { truncated } = truncateDiff([file("a.ts", 900), file("b.ts", 900)], 1000, 1000); - expect(truncated).toContain("b.ts"); - }); -}); - -describe("positions and markers", () => { - const added: Finding = { path: "a.ts", new_line: 42, old_line: null, severity: "blocker", title: "t", body: "b" }; - const deleted: Finding = { path: "d.ts", new_line: null, old_line: 17, severity: "warning", title: "t", body: "b" }; - - test("an added line maps to new_line only", () => { - const p = positionFor(added, REFS); - expect(p.new_line).toBe(42); - expect(p.old_line).toBeUndefined(); - expect(p.base_sha).toBe(REFS.base_sha); - }); - - test("a deleted line maps to old_line only", () => { - const p = positionFor(deleted, REFS); - expect(p.old_line).toBe(17); - expect(p.new_line).toBeUndefined(); - }); - - test("the summary carries a typed marker the parser reads back", () => { - const body = renderSummary({ summary: "s", findings: [] }, [], REFS.head_sha); - expect(markerShaOf(body)).toBe(REFS.head_sha); - expect(markerKindOf(body)).toBe("summary"); - }); - - test("markers without a kind still yield their sha", () => { - expect(markerShaOf("")).toBe("abc123abc123"); - expect(markerKindOf("")).toBeNull(); - }); -}); - -describe("planActions", () => { - const review = { - summary: "s", - findings: [ - { path: "a.ts", new_line: 1, old_line: null, severity: "blocker", title: "t1", body: "b1" }, - { path: "b.ts", new_line: 2, old_line: null, severity: "nit", title: "t2", body: "b2" }, - ] as Finding[], - }; - const notesWithMarker = JSON.parse(fixture("notes-with-marker.json")) as { id: number; body: string }[]; - const staleDiscussions = (JSON.parse(fixture("discussions-stale.json")) as { - id: string; - notes: { body: string; resolved: boolean }[]; - }[]).map((d) => ({ id: d.id, resolved: d.notes[0].resolved, body: d.notes[0].body })); - - const ctx = { - headSha: REFS.head_sha, - diffRefs: REFS, - truncated: [] as string[], - existingNotes: notesWithMarker, - existingDiscussions: staleDiscussions, - }; - - test("inline: resolves stale bot threads before posting, then updates the sticky note", () => { - const actions = planActions(review, { ...ctx, mode: "inline" }); - const types = actions.map((a) => a.type); - expect(types).toEqual(["resolve_discussion", "create_discussion", "create_discussion", "update_note"]); - expect((actions[0] as Extract).discussion_id).toBe( - "d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0", - ); - }); - - test("inline: never touches human threads", () => { - const actions = planActions(review, { ...ctx, mode: "inline" }); - const resolved = actions.filter((a) => a.type === "resolve_discussion"); - expect(resolved).toHaveLength(1); - }); - - test("summary: a single note action, updated in place when the marker exists", () => { - const actions = planActions(review, { ...ctx, mode: "summary" }); - expect(actions).toHaveLength(1); - expect(actions[0].type).toBe("update_note"); - }); - - test("the newest fallback note never captures the summary update", () => { - // The notes fixture lists a kind=finding fallback note (id 60) before the - // kind=summary note (id 101), matching GitLab's newest-first ordering. - const actions = planActions(review, { ...ctx, mode: "summary" }); - expect((actions[0] as Extract).note_id).toBe(101); - }); - - test("summary: creates the note when no marker exists yet", () => { - const bare = JSON.parse(fixture("notes-without-marker.json")) as { id: number; body: string }[]; - const actions = planActions(review, { ...ctx, mode: "summary", existingNotes: bare }); - expect(actions[0].type).toBe("create_note"); - }); - - test("log: plans nothing", () => { - expect(planActions(review, { ...ctx, mode: "log" })).toHaveLength(0); - }); -}); - -describe("executeActions", () => { - test("degrades a rejected position to a plain note", async () => { - const calls: { method: string; path: string }[] = []; - let discussionCalls = 0; - const http = async (method: string, path: string) => { - calls.push({ method, path }); - if (path === "/discussions" && method === "POST") { - discussionCalls += 1; - return { status: discussionCalls === 1 ? 400 : 201, body: null }; - } - return { status: 200, body: null }; - }; - const actions: Action[] = [ - { type: "create_discussion", body: "one", position: {}, fallback_body: "one-fallback" }, - { type: "create_discussion", body: "two", position: {}, fallback_body: "two-fallback" }, - { type: "create_note", body: "summary" }, - ]; - const { posted, fallbacks } = await executeActions(actions, http); - expect(posted).toBe(1); - expect(fallbacks).toBe(1); - expect(calls.filter((c) => c.path === "/notes")).toHaveLength(2); - }); - - test("surfaces non-400 failures instead of swallowing them", async () => { - const http = async () => ({ status: 500, body: null }); - const actions: Action[] = [{ type: "create_discussion", body: "x", position: {}, fallback_body: "y" }]; - await expect(executeActions(actions, http)).rejects.toThrow("500"); - }); -}); diff --git a/plugins/code-reviews/scripts/tests/templates/run-tests.sh b/plugins/code-reviews/scripts/tests/templates/run-tests.sh new file mode 100644 index 0000000..d5a69d9 --- /dev/null +++ b/plugins/code-reviews/scripts/tests/templates/run-tests.sh @@ -0,0 +1,116 @@ +#!/bin/sh +# ============================================================================= +# Template and cross-reference checks for the code-reviews plugin. +# +# This plugin ships no runtime -- every surface uses its host's own harness -- +# so there is nothing to unit test. What can rot is the shipped configuration: +# a YAML template that stops parsing, an instructions file missing the +# frontmatter Copilot requires, or a SKILL.md naming a reference that no longer +# exists. Those are what this suite pins. +# +# scripts/verify-all.sh discovers and runs this file. +# ============================================================================= + +set -u + +SUITE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PLUGIN_DIR=$(CDPATH= cd -- "$SUITE_DIR/../../.." && pwd) + +passed=0 +failed=0 + +pass() { passed=$((passed + 1)); printf ' ok %s\n' "$1"; } +fail() { failed=$((failed + 1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; } + +echo "code-reviews templates" + +# --- the plugin ships no runtime --------------------------------------------- +# The whole point of the design. If a script reappears here, either the design +# changed and this test should be deleted deliberately, or something crept back. + +runtime=$(find "$PLUGIN_DIR" -name '*.ts' -o -name '*.py' | grep -v '/tests/' || true) +if [ -z "$runtime" ]; then + pass "plugin ships no runtime code" +else + fail "plugin ships no runtime code" "$runtime" +fi + +# --- skills exist ------------------------------------------------------------- + +for skill in review install; do + if [ -f "$PLUGIN_DIR/skills/$skill/SKILL.md" ]; then + pass "skills/$skill/SKILL.md exists" + else + fail "skills/$skill/SKILL.md exists" + fi +done + +# --- instructions files carry the frontmatter Copilot requires ---------------- + +for f in $(find "$PLUGIN_DIR" -name '*.instructions.md'); do + name=$(basename "$f") + head1=$(head -1 "$f") + if [ "$head1" != "---" ]; then + fail "$name opens with frontmatter" "first line: $head1" + continue + fi + front=$(sed -n '2,/^---$/p' "$f") + missing="" + for key in description applyTo; do + printf '%s' "$front" | grep -q "^$key:" || missing="$missing $key" + done + if [ -z "$missing" ]; then + pass "$name has description and applyTo" + else + fail "$name has description and applyTo" "missing:$missing" + fi +done + +# --- YAML templates parse ----------------------------------------------------- + +if command -v bun >/dev/null 2>&1; then + for f in $(find "$PLUGIN_DIR/skills" -name '*.yml' -o -name '*.yaml'); do + name=$(basename "$f") + if err=$(bun -e ' + import { load } from "js-yaml"; + import { readFileSync } from "node:fs"; + load(readFileSync(process.argv[1], "utf8")); + ' "$f" 2>&1); then + pass "$name parses as YAML" + else + fail "$name parses as YAML" "$err" + fi + done +else + echo " note: bun not installed; skipping YAML parse checks" +fi + +# --- every referenced resource exists ---------------------------------------- +# Catches a SKILL.md or reference naming a file that was renamed or removed. + +for skill_md in "$PLUGIN_DIR"/skills/*/SKILL.md; do + skill_dir=$(dirname "$skill_md") + skill_name=$(basename "$skill_dir") + refs=$(grep -o '`\(references\|templates\)/[A-Za-z0-9._-]*`' "$skill_md" | tr -d '`' | sort -u) + for ref in $refs; do + if [ -e "$skill_dir/$ref" ]; then + pass "$skill_name references $ref" + else + fail "$skill_name references $ref" "no such file" + fi + done +done + +# --- shell templates are valid POSIX sh -------------------------------------- + +for f in "$PLUGIN_DIR"/skills/install/templates/pre-push; do + [ -f "$f" ] || continue + if sh -n "$f" 2>/dev/null; then + pass "$(basename "$f") is valid sh" + else + fail "$(basename "$f") is valid sh" + fi +done + +echo " $passed passed, $failed failed" +[ "$failed" -eq 0 ] || exit 1 diff --git a/plugins/code-reviews/skills/install/SKILL.md b/plugins/code-reviews/skills/install/SKILL.md new file mode 100644 index 0000000..ebfb593 --- /dev/null +++ b/plugins/code-reviews/skills/install/SKILL.md @@ -0,0 +1,75 @@ +--- +name: install +description: This skill should be used when the user asks to "set up automated code review", "install code review on this repo", "review every PR", "review every merge request", "add Claude code review to CI", "wire up a pre-push review hook", or "configure Copilot code review". It installs the review into an existing harness - GitHub Actions, Anthropic's managed Code Review, GitLab CI, local hooks, or a non-Claude engine - using each harness's own native mechanism and never a bespoke runner. +argument-hint: "[github-managed|github-actions|gitlab-ci|local|copilot|other-engine]" +allowed-tools: Read, Write, Edit, Bash, Grep, Glob +--- + +# Install automated code review + +Wire the `review` skill into a harness that already exists. This skill writes configuration and +copies templates; it never builds a runner and never handles a credential. + +## 1. Establish the guidance files first + +Every surface reads the same layers, so set them up before touching any harness. + +1. If `REVIEW.md` is absent at the repository root, copy + `${CLAUDE_PLUGIN_ROOT}/skills/review/templates/REVIEW.md` and cut it down to what the repository + actually wants. Every heading in it is optional; a short file beats a thorough one. +2. If the repository is under ACT and `ACT_CODE_REVIEW.md` is absent, copy + `${CLAUDE_PLUGIN_ROOT}/skills/review/templates/ACT_CODE_REVIEW.md`. It opens with `@REVIEW.md`, + so it extends rather than replaces what step 1 produced. +3. Read `${CLAUDE_PLUGIN_ROOT}/skills/review/references/guidance-layering.md` and tell the user + which of their chosen surfaces expand `@` and which need a flattened file. + +## 2. Choose a surface + +From `$ARGUMENTS`, or ask. More than one can be installed; they are independent. + +| Surface | Use when | Reference | +|---|---|---| +| `github-managed` | GitHub, and the organization has Claude Code Team or Enterprise. No workflow file, no API key. | `references/github-managed.md` | +| `github-actions` | GitHub, and the review should run in your own CI with your own key | `references/github-actions.md` | +| `gitlab-ci` | GitLab merge requests | `references/gitlab-ci.md` | +| `local` | Before pushing, or on demand in a session | `references/local.md` | +| `copilot` | The repository is reviewed by GitHub Copilot's native reviewer | `references/copilot-native.md` | +| `other-engine` | docker-agent, Codex, or Copilot CLI drives the review | `references/non-claude-engines.md` | + +Read the matching reference in full before writing anything. Each one states what the harness does +natively, what it cannot do, and the exact steps. + +## 3. Install + +Copy the template named by the reference, adapt it to the repository's existing conventions — +match the stage names in an existing `.gitlab-ci.yml`, match the trigger style of neighbouring +workflows — and keep every bound the template ships with. The timeouts, turn caps, and +non-blocking settings are cost and safety controls, not decoration. + +Where a reference calls for a generated file, generate it with its header intact and tell the user +the regeneration command. A generated file edited by hand diverges silently. + +## 4. Report what the user must do + +Some steps cannot be done from here. List them explicitly rather than leaving them implied: + +- **Credentials.** Name each variable or secret, where it is created, and how it must be scoped. + Never read, write, or echo a value. +- **Console settings.** Enabling the managed product, choosing a trigger mode, or turning on + Copilot's custom-instructions toggle are all outside the repository. +- **Fork exposure.** Say plainly that secrets must not be exposed to pipelines from forks: the + reviewed diff is untrusted input to a model with the job's environment in reach. + +## 5. Verify + +Recommend a first run before the setup is trusted: open a test pull or merge request, confirm the +review appears, and read what it produced. A review that runs but reports nothing useful is a +guidance problem, and `REVIEW.md` is where it gets fixed. + +## Resources + +References: `github-managed.md`, `github-actions.md`, `gitlab-ci.md`, `local.md`, +`copilot-native.md`, `non-claude-engines.md`. + +Templates: `claude-code-review.yml`, `gitlab-ci-review-job.yml`, `pre-push`, +`code-review.instructions.md`, `review-agent.yaml`. diff --git a/plugins/code-reviews/skills/install/references/copilot-native.md b/plugins/code-reviews/skills/install/references/copilot-native.md new file mode 100644 index 0000000..154cec5 --- /dev/null +++ b/plugins/code-reviews/skills/install/references/copilot-native.md @@ -0,0 +1,59 @@ +# GitHub Copilot native review + +Copilot's own reviewer runs on GitHub's side. Nothing from this plugin executes; the entire +integration is a guidance file Copilot reads. + +## Mechanism + +| File | Scope | +|---|---| +| `.github/copilot-instructions.md` | Repository-wide, every Copilot feature | +| `.github/instructions/.instructions.md` | Path-scoped via `applyTo` frontmatter; supported by Copilot code review and the coding agent | + +Use the second. Copy `templates/code-review.instructions.md` to +`.github/instructions/code-review.instructions.md`. + +Frontmatter: + +```yaml +--- +description: 'Code review guidance for this repository' +applyTo: '**' +--- +``` + +- `applyTo` takes one or more comma-separated globs. `'**'` covers the repository; a narrower glob + scopes guidance to a subtree, and several instruction files can coexist with different scopes. +- `excludeAgent: ["coding-agent"]` restricts a file to code review only. Omit it to let both use it. + +## This file is generated + +Copilot does not expand `@` imports, so it cannot follow `ACT_CODE_REVIEW.md` to `REVIEW.md`. The +instructions file is a flattened restatement of both layers, phrased for a reviewer that posts its +own native comments — so it carries the judgment, not the findings schema. + +Generate it with its header intact: + +```markdown + +``` + +Tell the user the rule that matters: when the review layers change, regenerate. The failure mode is +silent divergence, where Copilot enforces last quarter's policy while everything else enforces +this quarter's. + +## Two conditions outside the file + +1. Copilot code review must be enabled for the repository or organization, and the **use custom + instructions** toggle under Settings, Copilot, Code review must be on. +2. Instructions are read **from the pull request's head branch**. A PR branched before the file + merged does not see it. Say this — it is the usual reason a correct file appears to do nothing. + +## Scope honestly + +This surface is configured, not executed, by the plugin. Copilot decides what to flag, in its own +voice, with its own severity vocabulary. The instructions file steers it; it does not control it. +Where an organization needs findings graded exactly as the rest of this plugin grades them, a +GitHub Actions or managed review is the surface that delivers that, and Copilot's reviewer is +complementary rather than equivalent. diff --git a/plugins/code-reviews/skills/install/references/github-actions.md b/plugins/code-reviews/skills/install/references/github-actions.md new file mode 100644 index 0000000..207e26b --- /dev/null +++ b/plugins/code-reviews/skills/install/references/github-actions.md @@ -0,0 +1,67 @@ +# GitHub Actions + +Run the review in your own CI with your own credentials, using `anthropics/claude-code-action`. +Choose this over `github-managed.md` when the organization does not qualify for the managed +product, or when the trigger, model, or prompt must be under repository control. + +## Install + +Copy `templates/claude-code-review.yml` to `.github/workflows/claude-code-review.yml` and adapt the +trigger to the repository's conventions. + +## The two lines that decide where findings go + +```yaml +prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' +claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"' +``` + +- **`--comment`** is what makes findings post at all. Without it the review runs and writes to the + workflow log only. This is the correct default for a first run. +- **`claude_args`** must name the inline-comment tool even though the invoked skill's own + frontmatter already allows it: the action starts that MCP server only when `--allowedTools` names + it. Dropping this line produces a review that finds issues and silently posts none. + +## Which review skill to invoke + +Two are available and they are not interchangeable: + +| Prompt | What runs | +|---|---| +| `/code-review:code-review --comment ` | Anthropic's upstream plugin, installed via `plugin_marketplaces` + `plugins`. Multi-agent, generate-then-validate, high-signal filter. | +| `/code-reviews:review` | This plugin's skill, which reads `REVIEW.md` and `ACT_CODE_REVIEW.md` | + +Use the upstream plugin when the priority is the strongest generic bug-finding available, and this +plugin's skill when organization guidance must be applied. They can be combined by invoking the +upstream plugin and letting `CLAUDE.md` carry the ACT layer, since the upstream review reads +`CLAUDE.md` natively but does not read `REVIEW.md`. + +The shipped template invokes this plugin's skill and installs no external marketplace. + +## Permissions and secrets + +```yaml +permissions: + contents: read + pull-requests: read + issues: read + id-token: write +``` + +`pull-requests: read` is sufficient: the inline-comment MCP server writes through the action's own +app token, not through `gh`. `id-token: write` is required for the action's default GitHub App +authentication. + +The user creates one repository or organization secret — `ANTHROPIC_API_KEY`, or +`CLAUDE_CODE_OAUTH_TOKEN` for a subscription token, swapping the matching input. For an +organization-wide rollout prefer an API key: an OAuth token is tied to whoever generated it. + +> [!CAUTION] +> On public repositories GitHub withholds secrets from fork pull requests, so the review runs only +> on same-repository branches. Do not work around this: the diff under review is untrusted input to +> a model holding the job's environment. + +## Cost bounds + +Keep the template's `--max-turns` and job `timeout`, and consider a concurrency group so a rapid +series of pushes cancels superseded runs. Both meters run at once — Actions minutes and API tokens. diff --git a/plugins/code-reviews/skills/install/references/github-managed.md b/plugins/code-reviews/skills/install/references/github-managed.md new file mode 100644 index 0000000..7cd7ed5 --- /dev/null +++ b/plugins/code-reviews/skills/install/references/github-managed.md @@ -0,0 +1,83 @@ +# GitHub, managed Code Review + +Anthropic runs the review on its own infrastructure. No workflow file, no API key in the +repository, no runner minutes. This is the least work and the best default when the organization +qualifies. + +## Prerequisites + +- A Claude Code Team or Enterprise subscription. It is unavailable to organizations with Zero Data + Retention enabled. +- An Owner or Primary Owner in the Claude organization, with permission to install GitHub Apps. + +These are the user's to arrange; state them and stop if they are not met. Where the organization +does not qualify, `github-actions.md` is the equivalent self-hosted path. + +## Steps the user performs + +1. Open `claude.ai/admin-settings/claude-code`, find the Code Review section, click **Setup**. +2. Install the Claude GitHub App into the organization and grant it the target repositories. +3. Select which repositories to enable. +4. Set **Review Behavior** per repository: + +| Mode | Runs | Cost | +|---|---|---| +| Once after PR creation | When a PR opens or is marked ready | One review per PR | +| After every push | On each push; resolves threads when issues are fixed | Multiplies by push count | +| Manual | Only on `@claude review` | Nothing until asked | + +Manual mode suits high-traffic repositories. `@claude review always` opts a single PR into +push-triggered reviews without changing the repository default. + +## What this repository must contain + +`REVIEW.md` at the root. The managed product injects it verbatim into every agent in the review +pipeline as the highest-priority instruction block, which makes it the single most effective place +to tune what gets flagged. + +> [!IMPORTANT] +> `REVIEW.md` is pasted verbatim: `@` imports are **not** expanded and referenced files are **not** +> read. An `ACT_CODE_REVIEW.md` layer is therefore invisible to this surface. + +Flatten the layers so this surface sees both. Append the ACT layer's body — everything after its +`@REVIEW.md` line — to `REVIEW.md` beneath a marked section: + +```markdown + +...ACT layer body... + +``` + +Tell the user this section regenerates and must not be hand-edited. + +## What to expect + +- Findings post as inline comments on the lines they concern, tagged Important, Nit, or + Pre-existing, each with expandable reasoning. +- A **Claude Code Review** check run collects every finding in one severity-sorted table, and + annotates the Files changed tab. It always completes neutral, so it never blocks a merge through + branch protection. +- Reviews take roughly 20 minutes and are billed per run, scaling with diff size. + +## Gating merges on findings + +The check run never blocks. To gate in your own CI, read the machine-readable severity counts from +the last line of the check run's output: + +```sh +gh api repos/OWNER/REPO/commits//check-runs --jq '.check_runs[] | select(.name=="Claude Code Review") | .id' +gh api repos/OWNER/REPO/check-runs/CHECK_RUN_ID \ + --jq '.output.text | split("bughunter-severity: ")[1] | split(" -->")[0] | fromjson' +``` + +This returns counts per severity, for example `{"normal": 2, "nit": 1, "pre_existing": 0}`, where +`normal` is the Important count. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| No check run appears | The repository is not enabled, or the App lacks access to it | +| Check says issues found, no inline comments | Look at the check run Details table and the Files changed annotations; a line that moved rejects its inline comment | +| Review errored or timed out | Comment `@claude review` to retry. GitHub's **Re-run** button does not retrigger it | +| A spend-cap comment appears | The organization's monthly cap was reached; reviews resume next period or when an admin raises it | diff --git a/plugins/code-reviews/skills/install/references/gitlab-ci.md b/plugins/code-reviews/skills/install/references/gitlab-ci.md new file mode 100644 index 0000000..8150ef2 --- /dev/null +++ b/plugins/code-reviews/skills/install/references/gitlab-ci.md @@ -0,0 +1,87 @@ +# GitLab CI + +GitLab maintains its own Claude Code CI/CD integration. It is the harness; this plugin supplies the +review guidance the job's prompt points at. Nothing bespoke is needed, and in particular no script +that posts comments — the job's Claude Code process posts them itself through the GitLab MCP tools. + +> [!NOTE] +> The integration is in beta and maintained by GitLab, not Anthropic. Flags vary by CLI version; +> run `claude --help` inside a job to confirm what the installed version supports. + +## Install + +Copy `templates/gitlab-ci-review-job.yml` into `.gitlab-ci.yml`, matching the project's existing +stage names rather than appending a foreign-looking block. If the project uses `include:` for +shared templates, ask whether the job belongs in the template project instead. + +## How the job posts back + +```yaml +script: + - /bin/gitlab-mcp-server || true + - > + claude -p "..." + --allowedTools "Read Grep Glob mcp__gitlab" + --max-turns 25 +``` + +`/bin/gitlab-mcp-server` is a binary in the runner image that supplies `mcp__gitlab` tools inside +the job. Naming `mcp__gitlab` in `--allowedTools` is what lets Claude comment on the merge request. + +> [!IMPORTANT] +> This runner binary is **not** the HTTP MCP server at `https:///api/v4/mcp`. That one is for +> interactive sessions and authenticates over OAuth, which is unusable in CI. They are not +> interchangeable. + +The tool allowlist above is read-only plus GitLab: a reviewer has no reason to hold `Edit`, `Write`, +or a general `Bash`. A job that also implements changes is a different job. + +## Triggering + +```yaml +rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' +``` + +That is native and needs nothing else. Two refinements the template ships: + +- **Skip drafts.** `CI_MERGE_REQUEST_DRAFT` exists only on GitLab 17.10 and later, so the template + also matches a `Draft:` title prefix for older instances. +- **Start manual.** For a first run, `- if: '$CI_PIPELINE_SOURCE == "web"'` alone lets credentials + and permissions be confirmed by someone who chose to run it. + +> [!WARNING] +> `@claude`-on-comment is **not** native. GitLab does not run a job on a comment. It requires a +> project webhook on Comments (notes) calling the pipeline trigger API with `AI_FLOW_INPUT`, +> `AI_FLOW_CONTEXT`, and `AI_FLOW_EVENT`. That listener is separate infrastructure this plugin does +> not provide. Say so rather than leaving the user with a correct job that never fires. + +## Tokens + +| Token | Use | +|---|---| +| `CI_JOB_TOKEN` | The default. Scoped to the job, expires with it. | +| Project access token, `api` scope, masked as `GITLAB_ACCESS_TOKEN` | Only where the job needs permissions the job token does not carry | + +Prefer `CI_JOB_TOKEN`; a project access token is a long-lived credential. If commenting fails with +the job token on your instance, that is the case for the access token. + +## Provider credentials + +The user creates these; never handle the values: + +| Provider | Variables | +|---|---| +| Claude API | `ANTHROPIC_API_KEY`, masked, protected if the job runs only on protected refs | +| Amazon Bedrock | `AWS_ROLE_TO_ASSUME`, `AWS_REGION`, plus GitLab configured as an OIDC provider in AWS IAM and a role whose trust policy is restricted to this project and its protected refs. Set `CLAUDE_CODE_USE_BEDROCK: "1"`. | +| Google Cloud Agent Platform | `GCP_WORKLOAD_IDENTITY_PROVIDER` (without the `//iam.googleapis.com/` prefix), `GCP_SERVICE_ACCOUNT`, `GCP_PROJECT_ID`, `CLOUD_ML_REGION`. Set `CLAUDE_CODE_USE_VERTEX: "1"`. | + +Both cloud providers authenticate over OIDC with nothing stored, which is the reason to prefer them +where the organization already uses that cloud. + +## Cost bounds + +`--max-turns` and job `timeout` are the two controls that bound a task which turns out harder than +expected, and neither has a useful default for an unattended job. Add `interruptible: true` so a new +push supersedes an in-flight review, and limit concurrency so triggered jobs cannot pile up +unnoticed. diff --git a/plugins/code-reviews/skills/install/references/local.md b/plugins/code-reviews/skills/install/references/local.md new file mode 100644 index 0000000..1153701 --- /dev/null +++ b/plugins/code-reviews/skills/install/references/local.md @@ -0,0 +1,55 @@ +# Local + +Reviewing before the change ever reaches a pipeline. Nothing is installed for the first option — +it already exists. + +## In a session + +Claude Code ships `/code-review` as a built-in. It reviews the branch's commits ahead of upstream +plus uncommitted changes, runs as a background subagent with its own context, and takes a target: +a path, a PR number, a branch, or a range such as `main...my-feature`. + +| Flag | Effect | +|---|---| +| `--fix` | Applies findings to the working tree after the review | +| `--comment` | Posts findings as inline PR comments | +| effort level (`low` through `max`) | Trades coverage for confidence; `low` and `medium` report only high-confidence findings | + +> [!NOTE] +> The built-in reads `CLAUDE.md` but **does not read `REVIEW.md`**. Where the repository's review +> policy lives in `REVIEW.md`, invoke `/code-reviews:review` instead — reading and applying those +> layers is exactly what it adds. The two otherwise overlap heavily, and the built-in's multi-agent +> verification is stronger on generic bug-finding. + +Because a background review's `--fix` edits land outside session checkpoints, `/rewind` will not +undo them. Use git. + +## As a pre-push hook + +Copy `templates/pre-push` to `.git/hooks/pre-push` and mark it executable, or point an existing +hook manager (lefthook, husky, `core.hooksPath`) at it. + +The hook calls whichever agent CLI is already on `PATH` — `claude`, `codex`, or `copilot` — and +skips silently when none is found. There is no configuration and no path to set: a tool that is not +installed simply does not run. + +Two properties worth stating to the user: + +- **Advisory by default.** Findings print and the push proceeds. A hook that blocks on a + probabilistic reviewer strands people at the worst moment. +- **`.git/hooks` is per-clone and unversioned.** Each contributor installs it themselves, which is + the argument for a hook manager whose config is committed. + +`git push --no-verify` bypasses it, as with any hook. + +## Which to reach for + +| Situation | Use | +|---|---| +| Mid-work, want a second opinion | `/code-reviews:review` or `/code-review` in the session | +| About to push a branch | The pre-push hook | +| Reviewing someone else's PR locally | `/code-reviews:review ` | +| Every change, without anyone remembering | A CI surface, not this one | + +A local review is a convenience for the author. It is not a substitute for a pipeline review, +because it only runs when someone chooses to run it. diff --git a/plugins/code-reviews/skills/install/references/non-claude-engines.md b/plugins/code-reviews/skills/install/references/non-claude-engines.md new file mode 100644 index 0000000..cde6488 --- /dev/null +++ b/plugins/code-reviews/skills/install/references/non-claude-engines.md @@ -0,0 +1,66 @@ +# Non-Claude engines + +The review methodology is provider-agnostic prose, so any agent that accepts a prompt and can read +files can run it. What changes between engines is only the invocation. + +The pattern is the same everywhere: **point the engine at the guidance files and let it read them.** +Do not paste the rubric into the invocation, and do not build a wrapper that assembles a prompt — +the files are already on disk in the repository being reviewed. + +A workable prompt, verbatim, for any engine: + +```text +Review the changes on this branch against the review guidance in this repository. +Read, in order: REVIEW.md, then ACT_CODE_REVIEW.md and any file it references with @. +Report only findings that name a concrete failure scenario and cite path:line. +Print the findings; do not modify any file. +``` + +## docker-agent + +A declarative runner: a YAML file names a model, an instruction, and toolsets. Provider-agnostic — +swapping `model:` swaps vendors — and the binary is standalone, so no Docker daemon is involved +despite the name. + +Copy `templates/review-agent.yaml`, set `model:`, and run it headless: + +```sh +docker-agent run --exec review-agent.yaml --safety restricted +``` + +The shipped toolset is read-only on purpose: filesystem reads and a fixed `git log` command, with +no shell, no network, and no MCP. The diff under review is untrusted input to an unattended model, +and the toolset is what bounds a prompt-injected run — the approval flag governs prompting, the +toolset governs capability. + +Pin the release rather than tracking latest; headless flags and event shapes move between versions. + +## Codex + +```sh +codex exec "" +``` + +Verify flags against `codex exec --help` for the installed version before committing them to a +pipeline. + +## Copilot CLI + +```sh +copilot -p "" +``` + +Same caveat. Note that a prompt passed as an argument is bounded by the operating system's +per-argument limit, so a very large diff needs the engine's stdin mode where one exists. + +## Where these fit + +| Engine | Reasonable use | +|---|---| +| docker-agent | A pipeline that must not depend on any single vendor, or one running a local model | +| Codex, Copilot CLI | A team already standardized on that CLI | +| Claude Code | Everything else — it is the only engine whose review skills this plugin can invoke directly | + +None of these post comments on their own. Where a review must land on a merge request, use a +surface whose harness provides the posting tools: `gitlab-ci.md`, `github-actions.md`, or +`github-managed.md`. diff --git a/plugins/code-reviews/skills/install/templates/claude-code-review.yml b/plugins/code-reviews/skills/install/templates/claude-code-review.yml new file mode 100644 index 0000000..f77326f --- /dev/null +++ b/plugins/code-reviews/skills/install/templates/claude-code-review.yml @@ -0,0 +1,54 @@ +# Automated code review on every pull request. +# +# Copy to .github/workflows/claude-code-review.yml. +# +# Create one repository or organization secret before this runs: +# ANTHROPIC_API_KEY a Claude API key +# or, for a subscription token, CLAUDE_CODE_OAUTH_TOKEN, swapping the input +# below to claude_code_oauth_token. +# +# On public repositories GitHub withholds secrets from fork pull requests, so +# this runs only on branches in the same repository. That is intended: the +# reviewed diff is untrusted input to a model holding the job's environment. + +name: Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +# A new push supersedes an in-flight review rather than paying for both. +concurrency: + group: code-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + # read is sufficient: the inline-comment MCP server writes through the + # action's own app token, not through gh. + pull-requests: read + issues: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: "https://github.com/patterson-agents/actdata-plugins.git" + plugins: "code-reviews@actdata-plugins" + # --comment is what makes findings post. Without it the review runs + # and writes to the workflow log only. + prompt: "/code-reviews:review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" + # Required even though the skill's own frontmatter allows the tool: + # the action starts the inline-comment MCP server only when + # --allowedTools names it here. + claude_args: >- + --allowedTools "mcp__github_inline_comment__create_inline_comment" + --max-turns 25 diff --git a/plugins/code-reviews/skills/install/templates/code-review.instructions.md b/plugins/code-reviews/skills/install/templates/code-review.instructions.md new file mode 100644 index 0000000..f233443 --- /dev/null +++ b/plugins/code-reviews/skills/install/templates/code-review.instructions.md @@ -0,0 +1,52 @@ +--- +description: 'Code review guidance for this repository' +applyTo: '**' +--- + + + +# Code review instructions + +Review only the changed lines and the minimum surrounding context needed to judge them. + +## Report + +In priority order: + +1. **Correctness**: inverted conditions, off-by-one bounds, unhandled null or empty cases, broken + error propagation, behavior that differs from what a name or docstring promises. +2. **Security**: injection (SQL, shell, path, template), missing authorization, secrets in code or + logs, unsafe deserialization, SSRF, disabled TLS verification. Flag only with a concrete path + from untrusted input to a dangerous sink. +3. **Concurrency and state**: unguarded read-modify-write, missing idempotency where retries occur, + resources without a guaranteed release path. +4. **Error handling**: swallowed exceptions, catches broad enough to hide unrelated errors, errors + logged but not surfaced, fallbacks that mask failure. +5. **API contracts**: unversioned breaking changes, schema drift, migrations that are not backward + compatible with deployed code. + +## Do not report + +- Anything a linter, formatter, type checker, or compiler already catches. +- Style, naming, and formatting preferences. +- Pre-existing issues this change did not introduce. +- Speculative problems that depend on inputs you cannot show are reachable. +- Generic observations such as "needs more tests" or "could use better docs". +- The same root cause in more than one place; comment at the most representative location. + +## Every comment must + +State what breaks, name the concrete inputs or state that break it, and suggest a specific fix. +A claim about behavior needs evidence in code that is visible in the diff or its context — an +inference from a function or variable name is not evidence. + +## Severity + +Reserve blocking language for changes that would produce incorrect behavior, expose data, or cause +data loss. Phrase likely-but-conditional problems as warnings. Mark minor issues as nitpicks the +author may reasonably ignore. When torn between two levels, choose the lower one. + +## Volume + +Past roughly five minor comments, summarize the remainder as a count rather than posting each one. diff --git a/plugins/code-reviews/skills/install/templates/gitlab-ci-review-job.yml b/plugins/code-reviews/skills/install/templates/gitlab-ci-review-job.yml new file mode 100644 index 0000000..2e816dd --- /dev/null +++ b/plugins/code-reviews/skills/install/templates/gitlab-ci-review-job.yml @@ -0,0 +1,53 @@ +# Automated code review on every merge request. +# +# Add to .gitlab-ci.yml, adapting `stage:` to the project's existing stages. +# This uses the GitLab-maintained Claude Code CI/CD integration; the job's +# Claude Code process posts merge request comments itself through the +# mcp__gitlab tools, so no posting script is involved. +# +# CI/CD variables to create under Settings > CI/CD > Variables, all masked: +# ANTHROPIC_API_KEY the Claude API key +# For Amazon Bedrock or Google Cloud instead, see gitlab-ci.md. +# +# Do not expose these variables to pipelines from forks. + +code-review: + stage: test + image: node:24-alpine3.21 + rules: + # Draft merge requests are not reviewed. CI_MERGE_REQUEST_DRAFT needs + # GitLab 17.10+; the title check covers older instances. + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "true"' + when: never + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE =~ /^Draft:/' + when: never + # For a first run, comment out the line below and keep only the web rule, + # so a person confirms credentials and permissions before this is automatic. + - if: '$CI_PIPELINE_SOURCE == "web"' + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + variables: + GIT_DEPTH: "0" + before_script: + - apk add --no-cache git curl bash + - curl -fsSL https://claude.ai/install.sh | bash + # The installer places claude in ~/.local/bin, which is not on PATH here. + - export PATH="$HOME/.local/bin:$PATH" + script: + # Supplies the mcp__gitlab tools inside the job. This runner binary is not + # the HTTP MCP server at /api/v4/mcp -- see gitlab-ci.md. + - /bin/gitlab-mcp-server || true + - > + claude + -p "Review this merge request following the guidance in REVIEW.md and ACT_CODE_REVIEW.md, + including any file they reference with @. Report only findings that name a concrete failure + scenario and cite path:line. Post them on the merge request, one comment per unique issue, + with a summary note. Modify no files." + --allowedTools "Read Grep Glob mcp__gitlab" + --max-turns 25 + # Bounds a task that turns out harder than expected. Neither has a useful + # default for an unattended job. + timeout: 20m + # A probabilistic reviewer must never block a merge. + allow_failure: true + # A new push supersedes an in-flight review. + interruptible: true diff --git a/plugins/code-reviews/skills/install/templates/pre-push b/plugins/code-reviews/skills/install/templates/pre-push new file mode 100644 index 0000000..dddf139 --- /dev/null +++ b/plugins/code-reviews/skills/install/templates/pre-push @@ -0,0 +1,48 @@ +#!/bin/sh +# pre-push -- review the commits about to be pushed. +# +# Install: copy to .git/hooks/pre-push and mark it executable, or point a hook +# manager (lefthook, husky, core.hooksPath) at it. Note that .git/hooks is +# per-clone and unversioned, so each contributor installs it themselves. +# +# Runs whichever agent CLI is already on PATH and skips silently when none is. +# There is nothing to configure: install a different CLI, or put one earlier on +# PATH, to change which engine reviews. +# +# Advisory: findings print and the push proceeds. `git push --no-verify` skips +# this hook entirely. + +ZERO=0000000000000000000000000000000000000000 + +PROMPT='Review the changes in the given range against the review guidance in this repository. +Read, in order: REVIEW.md, then ACT_CODE_REVIEW.md and any file it references with @. +Report only findings that name a concrete failure scenario and cite path:line. +Print the findings; do not modify any file.' + +if command -v claude >/dev/null 2>&1; then + review() { claude -p "$PROMPT Range: $1" --allowedTools "Read Grep Glob" --max-turns 15; } +elif command -v codex >/dev/null 2>&1; then + review() { codex exec "$PROMPT Range: $1"; } +elif command -v copilot >/dev/null 2>&1; then + review() { copilot -p "$PROMPT Range: $1"; } +else + echo "pre-push: no agent CLI on PATH; skipping review." >&2 + exit 0 +fi + +while read -r _local_ref local_sha _remote_ref remote_sha; do + # Deleting a ref pushes nothing reviewable. + [ "$local_sha" = "$ZERO" ] && continue + + if [ "$remote_sha" = "$ZERO" ]; then + # New branch: compare against the default branch when it is known. + base=$(git merge-base origin/HEAD "$local_sha" 2>/dev/null) || base="" + else + base="$remote_sha" + fi + [ -n "$base" ] || continue + + review "$base...$local_sha" +done + +exit 0 diff --git a/plugins/code-reviews/skills/install/templates/review-agent.yaml b/plugins/code-reviews/skills/install/templates/review-agent.yaml new file mode 100644 index 0000000..f7ea872 --- /dev/null +++ b/plugins/code-reviews/skills/install/templates/review-agent.yaml @@ -0,0 +1,57 @@ +# docker-agent configuration for code review. +# +# Run headless: +# docker-agent run --exec review-agent.yaml --safety restricted +# +# The binary is standalone -- no Docker daemon is involved despite the name. +# Pin a release rather than tracking latest; headless flags and event shapes +# move between versions. +# +# SECURITY: the diff under review is untrusted input to an unattended model. +# The toolset below is read-only on purpose. Widening it is a security +# decision, not a convenience: the approval flag governs prompting, the +# toolset governs capability. + +agents: + root: + # REQUIRED: replace with the model for the chosen provider, and set that + # provider's API key in the environment. Examples: + # + # model: anthropic/claude-opus-5 # ANTHROPIC_API_KEY; recommended + # model: anthropic/claude-sonnet-5 # ANTHROPIC_API_KEY; lower cost + # model: openai/gpt-5.6-sol # OPENAI_API_KEY + # + # Local models work the same way with a dmr/ model string and no key. + # Provider list: https://docker.github.io/docker-agent/providers/overview/ + model: REPLACE_ME + + instruction: | + Review the changes on the current branch as a senior engineer. + + Read the review guidance in this repository before starting: REVIEW.md + first, then ACT_CODE_REVIEW.md and any file it references with an @ + prefix. Follow that guidance exactly; it overrides these instructions + where the two differ. + + Review only the changed lines and the minimum surrounding context needed + to judge them. Report a finding only when it names a concrete failure + scenario -- specific inputs or state producing a wrong result, a crash, + or a security consequence -- and cites path:line in code you have read. + Do not report style, formatting, pre-existing issues, or anything a + linter catches. + + Print the findings, most severe first, each with the defect, the failure + scenario, and a concrete suggestion. Say so plainly when there is nothing + to report. Modify no files. + + toolsets: + - type: filesystem + tools: ["read_file", "search_files_content"] + - type: script + shell: + changed_files: + description: The diff against the default branch + cmd: git diff origin/HEAD...HEAD + recent_history: + description: The last 30 commits + cmd: git log --oneline -n 30 diff --git a/plugins/code-reviews/skills/mr-review-agent/SKILL.md b/plugins/code-reviews/skills/mr-review-agent/SKILL.md deleted file mode 100644 index a4cf78a..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/SKILL.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -name: mr-review-agent -description: This skill should be used when the user asks to "review merge requests automatically", "set up AI code review", "run a review agent in the pipeline", "post review comments on MRs", "review every MR", or mentions docker-agent, CODEREVIEW_MODE, CODEREVIEW_ENGINE, GITLAB_TOKEN for review comments, post-mr-review.ts, codereview.sh, review-agent.yaml, review-rubric.md, a pre-push AI review hook, inline MR discussions from CI, or Copilot code review instructions. Covers the review contract, the engine matrix, delivery modes, tokens, and re-push semantics. ---- - -# Automated merge-request review - -An engine-agnostic code review that runs on every submitted merge request, plus the same review as -a git hook, an in-session command, or GitHub Copilot's native reviewer. - -## The contract, not the engine, is the product - -Everything is built around two stable artifacts in this skill's `references/` and shipped alongside -the scripts: - -- **`references/review-rubric.md`** -- what a reviewer looks for, the severity scale, and the - findings JSON contract `{summary, findings: [{path, new_line, old_line, severity, title, body}]}`. -- **Deterministic delivery scripts** -- `scripts/post-mr-review.ts` (CI, posts to GitLab) and - `scripts/codereview.sh` (local, prints a report). The engine only ever produces findings; the - scripts do everything with side effects. - -Engines are swappable via `CODEREVIEW_ENGINE`: - -| Engine | Runs | Status | -|---|---|---| -| `docker-agent` (default) | Pinned standalone binary; provider-agnostic `model:` in `review-agent.yaml`. No Docker daemon involved. | Tested pair | -| `claude` | `claude -p` headless with read-only tools; Bedrock/Vertex auth is covered by the act-gitlab-ci plugin | Tested pair | -| `codex` | `codex exec` | Best effort; verify flags per version | -| `copilot` | Copilot CLI | Best effort; verify flags per version | - -Any other engine: set `CODEREVIEW_ENGINE_CMD` to a command that reads the prompt on stdin and -prints the findings JSON. - -## Four surfaces - -| Surface | Entry point | Setup | -|---|---|---| -| GitLab CI on every MR | `examples/mr-review-job.yml` running `post-mr-review.ts` | `/code-reviews:setup-mr-review gitlab-ci` | -| Git hooks / scripts | `scripts/codereview.sh`, `examples/git-hook-pre-push.sh` | `/code-reviews:setup-mr-review git-hook` | -| In-session, any host | the `review-mr` command | installed with the plugin | -| GitHub Copilot native review | `examples/copilot-code-review.instructions.md` | `/code-reviews:setup-mr-review copilot` | - -The CI scripts are **copied into the target repository** (conventionally `.gitlab/codereview/`) -because a CI job cannot resolve plugin paths. Re-run the setup command to pick up plugin updates. - -## Delivery modes (GitLab CI) - -`CODEREVIEW_MODE` selects how findings reach the MR; see `references/review-modes.md` for detail. - -| Mode | Needs | Result | -|---|---|---| -| `inline` (default) | `GITLAB_TOKEN` | One positioned discussion per finding plus a sticky summary note. Positions GitLab rejects degrade to plain notes. On re-push, stale bot threads are resolved and the summary updates in place. | -| `summary` | `GITLAB_TOKEN` | The sticky summary note only. | -| `log` | nothing | Job log plus `codereview-artifacts/`. Automatic fallback when `GITLAB_TOKEN` is unset. | - -## Tokens: when CI_JOB_TOKEN is not enough - -`CI_JOB_TOKEN` cannot create notes or discussions on a merge request, which is exactly what -`inline` and `summary` modes do. Those modes need `GITLAB_TOKEN`: a project access token with -`api` scope and Developer role, stored masked. - -This is the documented exception to the act-gitlab-ci plugin's guidance (in its -`claude-code-ci-jobs` skill) to prefer `CI_JOB_TOKEN`. That guidance stands wherever the job -token's permissions suffice (cloning, package registries, trigger tokens); posting review -comments is a case where they do not. Without a project access token, run `log` mode -- it needs -no token at all. - -> [!CAUTION] -> The diff under review is untrusted input to the model. Keep engine toolsets read-only (the -> shipped `review-agent.yaml` already is), never hand the engine `GITLAB_TOKEN` (the wrapper -> strips it from the engine's environment), and never expose the model API key or `GITLAB_TOKEN` -> to pipelines from forks. - -## Cost and blast bounds - -The CI job carries `timeout: 15m`, `interruptible: true`, and `allow_failure: true`, so the -reviewer never blocks a merge and a new push supersedes the in-flight run. The wrapper adds diff -budgets on every surface (`CODEREVIEW_MAX_FILE_LINES`, `CODEREVIEW_MAX_DIFF_LINES` -- truncations -are listed in the summary), a turn cap where the engine supports one (`CODEREVIEW_MAX_TURNS`, on -the `claude` engine), and, in token modes, a no-op guard: a pipeline retry on an already-reviewed -head SHA exits before the engine runs. - -## Boundary with the act-gitlab-ci plugin - -The act-gitlab-ci plugin's `claude-code-ci-jobs` skill runs Claude Code in CI as an **actor**: it -edits files, commits, and opens MRs from instructions. This skill runs an engine as a -**reviewer**: read-only, structured findings, deterministic posting. Neither replaces the other; a -repository can carry both jobs, and neither plugin requires the other. - -## Additional resources - -### Reference files - -- **`references/review-rubric.md`** -- the reviewer instruction, severity scale, findings contract -- **`references/review-modes.md`** -- mode and engine matrices, tokenless constraints, re-push semantics -- **`references/docker-agent-config.md`** -- config anatomy, toolsets, safety flags, provider swap -- **`references/gitlab-discussions-api.md`** -- position objects, diff_refs, the 400 fallback, marker dedup -- **`references/copilot-code-review.md`** -- how Copilot native review consumes instruction files - -### Examples - -- **`examples/mr-review-job.yml`** -- the CI job template -- **`examples/review-agent.yaml`** -- the docker-agent config template -- **`examples/copilot-code-review.instructions.md`** -- the Copilot instructions file -- **`examples/git-hook-pre-push.sh`** -- a pre-push hook wiring `codereview.sh` diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/copilot-code-review.instructions.md b/plugins/code-reviews/skills/mr-review-agent/examples/copilot-code-review.instructions.md deleted file mode 100644 index fd0fac4..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/examples/copilot-code-review.instructions.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -applyTo: "**" ---- - -# Code review instructions - -These instructions apply to automated code review of pull requests. - -Review only the changed lines and the minimum surrounding context needed to judge them. Look for, -in priority order: - -1. Correctness: logic errors, off-by-one, inverted conditions, unhandled null or undefined values, - broken error propagation, behavior that differs from what the name or documentation promises. -2. Security: injection (SQL, shell, path, template), secrets or tokens in code, missing - authorization checks, unsafe deserialization, SSRF, insecure defaults. -3. Concurrency and state: race conditions, unguarded shared state, missing idempotency where - retries occur, resource leaks. -4. Error handling: swallowed exceptions, empty catch blocks, errors logged but not surfaced, - fallbacks that hide failure. -5. API contracts: breaking changes to public interfaces, schema or serialization drift, - incompatible migrations. - -Do not comment on: - -- Style or formatting that a linter or formatter already enforces. -- Preferences with no concrete failure mode. -- Lines the pull request did not touch, unless the change breaks them. -- The same root cause more than once; comment at the most representative location. - -For each issue, state what breaks and under which conditions, and make the comment concrete enough -to act on without follow-up questions. Grade severity conservatively: reserve blocking language for -incorrect behavior, security holes, or data loss; phrase likely-but-conditional problems as -warnings; mark minor issues as nitpicks the author may reasonably ignore. diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh b/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh deleted file mode 100644 index 5b38dbc..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/examples/git-hook-pre-push.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/sh -# pre-push -- AI-review the commits about to be pushed. -# -# Install: copy to .git/hooks/pre-push and mark executable, or point a hook -# manager (lefthook, husky, core.hooksPath) at it. Requires codereview.sh from -# the same review bundle; adjust CODEREVIEW_SH if it lives elsewhere. -# -# Advisory by default: findings print, the push proceeds. Set -# CODEREVIEW_BLOCKING=1 to abort the push when a blocker is found. Either way, -# `git push --no-verify` skips the hook entirely. - -CODEREVIEW_SH="${CODEREVIEW_SH:-.gitlab/codereview/codereview.sh}" -ZERO=0000000000000000000000000000000000000000 - -if [ ! -f "$CODEREVIEW_SH" ]; then - echo "pre-push: $CODEREVIEW_SH not found; skipping AI review." >&2 - exit 0 -fi - -status=0 -while read -r _local_ref local_sha _remote_ref remote_sha; do - # Deleting a ref pushes nothing reviewable. - [ "$local_sha" = "$ZERO" ] && continue - - if [ "$remote_sha" = "$ZERO" ]; then - # New branch: review against the default branch when it exists. - base=$(git merge-base "origin/HEAD" "$local_sha" 2>/dev/null) || base="" - else - base="$remote_sha" - fi - [ -n "$base" ] || continue - - sh "$CODEREVIEW_SH" "$base...$local_sha" || status=1 -done - -# Advisory unless the user opted into gating: an engine failure or a finding -# must not strand a push by default. -[ "${CODEREVIEW_BLOCKING:-0}" = "1" ] || exit 0 -exit $status diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml b/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml deleted file mode 100644 index 36f0317..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/examples/mr-review-job.yml +++ /dev/null @@ -1,71 +0,0 @@ -# Automated AI review of every merge request. -# -# Add this job to .gitlab-ci.yml (adapt `stage:` to the project) after copying -# the review bundle into .gitlab/codereview/ -- the setup-mr-review command does -# both. The job runs an AI engine over the MR diff and delivers findings per -# CODEREVIEW_MODE; see review-modes.md in the mr-review-agent skill. -# -# CI/CD variables to create under Settings > CI/CD > Variables (all masked): -# - The model provider key the engine needs, e.g. ANTHROPIC_API_KEY. -# - GITLAB_TOKEN (optional): a project access token with `api` scope, -# Developer role or above. Without it the job cannot post MR comments -# (CI_JOB_TOKEN does not carry that permission) and the review lands in -# the job log and artifacts instead. -# Do not expose either variable to pipelines from forks: the reviewed code is -# untrusted input to the model. - -ai-mr-review: - stage: test - image: oven/bun:1 - rules: - # CI_MERGE_REQUEST_DRAFT needs GitLab 17.10+; the title regex covers - # older instances. Draft MRs are also skipped server-side by the wrapper. - - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_DRAFT == "true"' - when: never - - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TITLE =~ /^Draft:/' - when: never - - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - variables: - # Tokenless (log) mode diffs locally against the MR base, which a shallow - # clone cannot reach. - GIT_DEPTH: "0" - CODEREVIEW_MODE: "inline" # inline | summary | log - CODEREVIEW_ENGINE: "docker-agent" # docker-agent | claude | codex | copilot - DOCKER_AGENT_VERSION: "v1.124.0" # pin; never latest - DOCKER_AGENT_SHA256: "" # optional: recorded checksum of the pinned binary; verified when set - TELEMETRY_ENABLED: "false" - before_script: - # oven/bun images carry no curl; download the pinned standalone binary - # with bun itself. No Docker daemon is involved at any point. - - | - if [ "$CODEREVIEW_ENGINE" = "docker-agent" ]; then - bun -e 'const v = process.env.DOCKER_AGENT_VERSION; - const url = `https://github.com/docker/docker-agent/releases/download/${v}/docker-agent-linux-amd64`; - const res = await fetch(url); - if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`); - await Bun.write("/usr/local/bin/docker-agent", res);' - if [ -n "$DOCKER_AGENT_SHA256" ]; then - bun -e 'const { createHash } = await import("node:crypto"); - const data = new Uint8Array(await Bun.file("/usr/local/bin/docker-agent").arrayBuffer()); - const sum = createHash("sha256").update(data).digest("hex"); - if (sum !== process.env.DOCKER_AGENT_SHA256) throw new Error(`docker-agent sha256 mismatch: ${sum}`);' - fi - chmod +x /usr/local/bin/docker-agent - fi - - | - if [ "$CODEREVIEW_ENGINE" = "claude" ]; then - # Pin CLAUDE_CODE_VERSION to a known-good release for reproducible runs. - bun add -g "@anthropic-ai/claude-code${CLAUDE_CODE_VERSION:+@$CLAUDE_CODE_VERSION}" - fi - script: - - bun .gitlab/codereview/post-mr-review.ts - timeout: 15m - # A broken or flaky reviewer must never block a merge. - allow_failure: true - # A new push supersedes the in-flight review instead of paying for both. - interruptible: true - artifacts: - when: always - expire_in: 1 week - paths: - - codereview-artifacts/ diff --git a/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml b/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml deleted file mode 100644 index 620af35..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/examples/review-agent.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# docker-agent configuration for automated merge-request review. -# -# Copied into a target repository (conventionally .gitlab/codereview/) by the -# setup-mr-review command. The full review rubric and the diff arrive in the -# prompt built by post-mr-review.ts / codereview.sh; this file sets the model, -# the persona, and -- deliberately -- a read-only toolset. -# -# SECURITY: the diff under review is untrusted input to the model. This agent -# runs unattended, so its capabilities are bounded here, not by approval -# prompts: filesystem reads and one fixed git command, no shell, no network, -# no MCP. Widening this toolset widens what a prompt-injected review can do. - -agents: - root: - # REQUIRED: replace with the model for the chosen provider, and set the - # provider's API key as a masked CI/CD variable. Examples: - # - # model: anthropic/claude-opus-5 # ANTHROPIC_API_KEY; recommended default - # model: anthropic/claude-sonnet-5 # ANTHROPIC_API_KEY; lower cost - # model: anthropic/claude-fable-5 # ANTHROPIC_API_KEY - # model: openai/gpt-5.6-sol # OPENAI_API_KEY - # - # Local models work through Docker Model Runner with a dmr/ model string. - # Provider list: https://docker.github.io/docker-agent/providers/overview/ - model: REPLACE_ME - - instruction: | - Act as a senior engineer reviewing a merge request. The prompt contains - the review rubric, the merge request context, and the diff. Follow the - rubric exactly: review only changed lines, report only concrete defects, - and use the blocker/warning/nit severity scale as defined. - - Read surrounding files only when needed to judge a changed line, and - never modify anything. - - Respond with only the findings-contract JSON object described in the - rubric. No prose before or after it. - - toolsets: - - type: filesystem - tools: ["read_file", "search_files_content"] - - type: script - shell: - recent_history: - description: The last 30 commits, for context on churn and intent - cmd: git log --oneline -n 30 diff --git a/plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md b/plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md deleted file mode 100644 index 453a248..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/references/copilot-code-review.md +++ /dev/null @@ -1,47 +0,0 @@ -# GitHub Copilot native code review - -How the same review rubric reaches GitHub Copilot's built-in pull-request reviewer, which runs on -GitHub's side with no pipeline, engine, or script from this plugin. - -## Mechanism - -Copilot code review reads repository custom instructions from the PR's head branch: - -| File | Scope | -|---|---| -| `.github/copilot-instructions.md` | Repository-wide, all Copilot features | -| `.github/instructions/.instructions.md` | Path-specific via `applyTo` frontmatter; supported by Copilot code review and the coding agent | - -The shipped template `examples/copilot-code-review.instructions.md` is the second kind with -`applyTo: "**"`: the rubric's criteria, exclusions, and severity discipline rephrased for a -reviewer that posts its own native review comments. The findings JSON contract does not apply -here -- Copilot formats its own output -- so the instructions carry the judgment, not the schema. - -Frontmatter options: - -- `applyTo: "[, ...]"` -- which files the instructions cover. `"**"` covers the - repository; narrower globs (for example `app/models/**/*.rb`) scope review guidance to a - subtree, and several instruction files can coexist. -- `excludeAgent: "code-review"` restricts a file to the coding agent only; omit it so both use - the instructions. - -## Installation - -`/code-reviews:setup-mr-review copilot` copies the template to -`.github/instructions/code-review.instructions.md` in the target repository. Two conditions are -outside the file's control and worth stating to the user: - -1. Copilot code review must be enabled for the repository or organization, and the - "use custom instructions" toggle under Settings, Copilot, Code review must be on. -2. Instructions take effect for pull requests whose **head branch contains the file** -- reviews - of branches cut before the file merged do not see it. - -## Keeping the surfaces aligned - -The instructions file is a manual restatement of `review-rubric.md`, not a generated artifact. -When the rubric changes, update the instructions file in the same change; the divergence to watch -for is severity language, which the rubric defines precisely and prose restatements erode. - -GitLab has no equivalent instruction-file hook: its built-in reviewer surface is limited to -GitLab Duo, which is configured product-side. On GitLab, this plugin's CI job is the automated -reviewer. diff --git a/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md b/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md deleted file mode 100644 index 62bcbcf..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/references/docker-agent-config.md +++ /dev/null @@ -1,92 +0,0 @@ -# docker-agent for merge-request review - -How the shipped `review-agent.yaml` uses docker-agent, and what is safe to change. -Upstream documentation: https://docker.github.io/docker-agent/ - -## What docker-agent is here - -A declarative agent runner: a YAML file names a model, an instruction, and toolsets; the binary -runs the loop. The review integration uses its headless mode only: - -```sh -docker-agent run --exec review-agent.yaml --json - < prompt.md -``` - -- `--exec` runs without the interactive TUI and exits when the conversation ends. -- `--json` emits newline-delimited JSON events (messages, tool calls, results). The wrapper's - parser reads the review out of this stream and saves it whole as `transcript.ndjson`. -- `-` takes the prompt on stdin, which avoids argv length limits on large diffs. - -The binary is standalone. `DOCKER_AGENT_VERSION` pins the GitHub release the CI job downloads -(asset `docker-agent-linux-amd64`); no Docker daemon, docker-in-docker, or `docker` CLI is -involved despite the name. - -## Config anatomy - -```yaml -agents: - root: - model: anthropic/claude-opus-5 # example; the shipped template says REPLACE_ME - instruction: | - ...persona and output discipline... - toolsets: - - type: filesystem - tools: ["read_file", "search_files_content"] - - type: script - shell: - recent_history: - description: The last 30 commits - cmd: git log --oneline -n 30 -``` - -The shipped template keeps the instruction short on purpose: the rubric and the diff arrive in the -prompt the wrapper builds, so the config only pins persona, output discipline, and capability. - -## Swapping providers - -Change `model:` and set the provider's key as a masked CI/CD variable. The engine reads standard -env var names (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, and so on -- see the -provider pages upstream). Local models via Docker Model Runner or compatible endpoints work the -same way with a `dmr/...` or custom provider model string; those need no key at all. - -## Toolsets: the security boundary - -The review agent runs unattended over untrusted diff content, so capability is bounded in the -config rather than by approval prompts: - -| Allowed | Why | -|---|---| -| `filesystem` with `read_file`, `search_files_content` | Judging a changed line often needs surrounding code | -| `script` with fixed, argument-free commands | Context (history) without an open shell | - -| Excluded | Why | -|---|---| -| `shell` | An open shell plus prompt injection equals arbitrary execution | -| `fetch` / remote `mcp` | Network egress is an exfiltration channel for anything in the environment | -| any write-capable tool | A reviewer that can edit is an actor, and belongs to a Claude-Code-as-CI-actor job (the act-gitlab-ci plugin) instead | - -Widening the toolset is a security decision, not a convenience: everything in the job's -environment (provider key included) is within reach of a prompt-injected agent with a shell. - -## Safety flags - -| Flag | Behavior | Use | -|---|---|---| -| `--safety restricted` | Denies tool calls outside configured permissions without prompting | The default (`CODEREVIEW_ENGINE_FLAGS`) | -| `--yolo` | Auto-approves everything | Fallback if a version denies the read-only tools under `restricted`; acceptable only because the toolset is already read-only | -| `--sandbox` | Runs the agent in a VM | Not usable inside typical CI containers (no nested virtualization) | - -## Structured output - -docker-agent has a structured-output feature (upstream: configuration/structured-output) that can -force the findings schema at the model layer. The shipped setup does not depend on it: the -instruction demands a JSON-only answer and the wrapper's parser tolerates events, fences, and -wrappers. On a docker-agent version whose structured output proves stable, adding it to -`review-agent.yaml` tightens the contract without changing anything downstream. - -## Version pinning - -`DOCKER_AGENT_VERSION` is pinned in `mr-review-job.yml` (`v1.124.0` as shipped). Headless flags -and event shapes are young and move between releases: bump deliberately, re-run the test suite, -and re-check `--safety` behavior when doing so. For stronger supply-chain footing, verify the -downloaded binary against a recorded checksum before `chmod +x` and fail the job on mismatch. diff --git a/plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md b/plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md deleted file mode 100644 index d229ae0..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/references/gitlab-discussions-api.md +++ /dev/null @@ -1,85 +0,0 @@ -# GitLab notes, discussions, and diff positions - -The API mechanics behind inline review delivery, and why they live in a deterministic script -rather than in the agent. - -## Notes vs discussions - -| Object | Endpoint | Used for | -|---|---|---| -| Note | `POST/PUT /projects/:id/merge_requests/:iid/notes[/:note_id]` | The sticky summary, and the fallback for unmappable findings | -| Discussion | `POST /projects/:id/merge_requests/:iid/discussions` | One thread per finding, anchored to a diff line, resolvable | - -A discussion with a `position` renders on the changed line in the diff view -- the GitHub -"inline review comment" equivalent. A plain note renders in the MR activity stream. - -## Position objects - -```json -{ - "body": "**[Blocker]** ...", - "position": { - "position_type": "text", - "base_sha": "", - "head_sha": "", - "start_sha": "", - "new_path": "src/payments/charge.ts", - "old_path": "src/payments/charge.ts", - "new_line": 42 - } -} -``` - -Rules the wrapper (`positionFor` in `post-mr-review.ts`) encodes: - -- All three SHAs come from the MR's `diff_refs`, returned by - `GET /projects/:id/merge_requests/:iid/changes`. Constructing them from other CI variables - drifts on rebases and merged-result pipelines; use `diff_refs`. -- An **added or modified** line: `new_line` only. -- A **deleted** line: `old_line` only. -- `old_path` is required even when the file was not renamed. - -## The 400 fallback is a designed path - -GitLab validates the position against the actual diff and answers `400 Bad Request` when it -cannot map it -- typically a context line the MR did not change, a line index past a hunk, or a -rename edge case. Models produce such positions at a steady rate, so the wrapper treats 400 as -expected: the finding is delivered as a plain note prefixed with `path:line` instead of being -dropped. Any other non-2xx status is a real error and fails the run. - -This is the core reason posting is deterministic code and not an agent tool call: the position -contract is exacting, silent partial delivery is unacceptable, and a fixture can pin the fallback -behavior in tests (`scripts/tests/mr-review/`). - -## Marker-based stickiness - -Every posted body ends with ``, -invisible in rendered markdown. `kind` is `summary` on the sticky note and `finding` on -discussions and fallback notes -- the distinction is load-bearing, because GitLab lists notes -newest-first and a fallback note would otherwise be mistaken for the summary and overwritten. -The wrapper identifies its own past output purely by this marker: - -| Check | Action | -|---|---| -| Note with a `kind=summary` marker exists | `PUT` the summary onto it instead of posting a new one | -| That marker's SHA equals the current head | The head is already reviewed; exit without running the engine | -| Discussion with a marker, older SHA, unresolved | `PUT resolved=true`, then post fresh findings | -| No marker (a human's note or thread) | Never touched | - -Fallback notes are plain notes, so unlike discussions they cannot be resolved on re-push; stale -ones stay in the activity stream, identifiable by the older SHA in their marker. - -Identifying by marker rather than by author makes the behavior independent of which token or bot -user posted the earlier review. - -## Token requirements - -Creating notes and discussions on an MR requires a token with `api` scope acting as a member with -at least the Developer role -- a project access token stored masked as `GITLAB_TOKEN`. -`CI_JOB_TOKEN`'s permission set does not include these endpoints on self-managed or gitlab.com -instances, which is why tokenless pipelines run `log` mode. The wrapper authenticates with the -`PRIVATE-TOKEN` header. - -Pagination: the wrapper reads the first 100 notes and discussions. An MR with more bot-relevant -history than that is degenerate; the marker search only needs the summary note, which stays -findable because it is updated, not re-posted. diff --git a/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md b/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md deleted file mode 100644 index c46d3c1..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/references/review-modes.md +++ /dev/null @@ -1,83 +0,0 @@ -# Delivery modes, engines, and re-push semantics - -## Mode matrix - -`CODEREVIEW_MODE` on the CI job selects delivery. The wrapper resolves the effective mode at -runtime: any comment mode without `GITLAB_TOKEN` downgrades to `log` with a notice on stderr, -because `CI_JOB_TOKEN` cannot create MR notes or discussions. - -| Mode | Token | What the author sees | -|---|---|---| -| `inline` | `GITLAB_TOKEN` | A discussion anchored to each finding's diff line, plus one summary note pinned to the MR. The closest GitLab equivalent of a GitHub Copilot or claude-code-action review. | -| `summary` | `GITLAB_TOKEN` | The summary note only: counts, a findings table with `path:line` locations, truncation notices. | -| `log` | none | The rendered report in the job log, plus artifacts. | - -Artifacts are written in every mode, under `codereview-artifacts/`: - -| File | Content | -|---|---| -| `transcript.ndjson` | The engine's raw output, for debugging a bad review | -| `findings.json` | The validated findings-contract object | -| `review.md` | The rendered summary, as it would appear on the MR | - -## Diff acquisition - -| Mode | Source | Constraint | -|---|---|---| -| `inline`, `summary` | `GET /projects/:id/merge_requests/:iid/changes` | Also supplies `diff_refs` for positioning and the server-side draft flag | -| `log` | `git diff $CI_MERGE_REQUEST_DIFF_BASE_SHA...HEAD` | The base SHA must be reachable: set `GIT_DEPTH: "0"` on the job (the template does) or fetch it explicitly | - -Diff budgets apply in both paths: `CODEREVIEW_MAX_FILE_LINES` (default 1500) per file and -`CODEREVIEW_MAX_DIFF_LINES` (default 6000) total. Files cut by either budget are named in the -summary so a partial review never masquerades as a full one. - -## Engine matrix - -| `CODEREVIEW_ENGINE` | Invocation | Auth | Notes | -|---|---|---|---| -| `docker-agent` | `docker-agent run --exec review-agent.yaml --json --safety restricted -` | Provider key per `model:` in the config | Default. Provider-agnostic. Standalone binary, pinned by `DOCKER_AGENT_VERSION`; no Docker daemon. | -| `claude` | `claude -p --output-format json --max-turns N --allowedTools "Read Grep Glob"` | `ANTHROPIC_API_KEY`, subscription token, or the Bedrock/Vertex setups documented by the act-gitlab-ci plugin | The Docker-free pipeline path. | -| `codex` | `codex exec --json` | OpenAI credentials | Best effort: flags move between versions; check `codex exec --help`. | -| `copilot` | `copilot -p ` | GitHub Copilot auth | Best effort: same caveat. The prompt travels as one argv element, so very large diffs can exceed the OS argument limit -- lower `CODEREVIEW_MAX_DIFF_LINES` or switch to `CODEREVIEW_ENGINE_CMD` with a stdin-reading invocation. | -| any | `CODEREVIEW_ENGINE_CMD` | caller's concern | Full command via `sh -c` on both surfaces (CI wrapper and local harness); prompt on stdin; must print the findings JSON. | - -The local harness auto-detects an engine when `CODEREVIEW_ENGINE` is unset: the first of -`docker-agent`, `claude`, `codex`, `copilot` found on PATH. - -Safety flags for docker-agent default to `--safety restricted` (`CODEREVIEW_ENGINE_FLAGS` -overrides). If a docker-agent version denies the read-only tools under `restricted`, `--yolo` is -an acceptable fallback **only because** the config's toolsets are already read-only: the approval -flag governs prompting, the toolset governs capability. - -The engine subprocess never receives `GITLAB_TOKEN`, `GITLAB_ACCESS_TOKEN`, or `CI_JOB_TOKEN` -- -the wrapper and the harness both strip them, so a prompt-injected engine has no way to post. -The rest of the job environment necessarily remains reachable, the model provider key included: -that is what makes the read-only toolset and the no-network rule load-bearing rather than -decorative. - -## Sticky and re-push semantics - -Every body the wrapper posts embeds `` -(`kind=summary` on the sticky note, `kind=finding` on discussions and fallback notes). - -- **Summary note**: found by marker and updated in place (`PUT`); created once, then stable, so - the MR never accumulates a stack of summaries. -- **Pipeline retry** (same head SHA as the marker): the run exits before invoking the engine. - Retries are free. -- **New push** (different head SHA): stale bot discussions -- marker present, SHA differs, still - unresolved -- are resolved, fresh discussions are posted against the new diff, and the summary - updates. Resolved threads stay visible but collapsed, preserving the audit trail. Human threads - are never touched: no marker, no action. -- **Draft MRs**: skipped by the job rules (`CI_MERGE_REQUEST_DRAFT` on GitLab 17.10+, a - `Draft:` title regex elsewhere) and, belt-and-braces, by the wrapper via the API's draft flag - in token modes. In `log` mode the wrapper never calls the API, so the job rules are the only - draft guard. - -## Failure behavior - -| Failure | Result | -|---|---| -| Engine output has no valid findings object | Exit 1; transcript saved to artifacts; `allow_failure: true` keeps the MR mergeable | -| A finding's position is rejected (HTTP 400) | That finding posts as a plain note prefixed `path:line`; the rest post normally. Expected for context lines and renames, not an error. | -| Other GitLab API errors | Exit 1 with the status code; nothing is silently dropped | -| Empty diff | Exit 0, "nothing to review" | diff --git a/plugins/code-reviews/skills/mr-review-agent/references/review-rubric.md b/plugins/code-reviews/skills/mr-review-agent/references/review-rubric.md deleted file mode 100644 index 69e9209..0000000 --- a/plugins/code-reviews/skills/mr-review-agent/references/review-rubric.md +++ /dev/null @@ -1,83 +0,0 @@ -# Review rubric and findings contract - -The single source of truth for what an automated reviewer looks for and how it reports it. Every -surface derives from this file: the docker-agent config embeds the instruction, the CI wrapper and -shell harness send it to CLI engines, the `review-mr` command applies it in-session, and the GitHub -Copilot instructions file restates it in Copilot's format. A change here is a change to all of them. - -## Reviewer instruction - -Act as a senior engineer reviewing a merge request. Review **only the changed lines** in the -provided diff and the minimum surrounding context needed to judge them. - -Look for, in priority order: - -1. **Correctness** -- logic errors, off-by-one, inverted conditions, unhandled null/undefined, - broken error propagation, results that differ from what the name or docs promise. -2. **Security** -- injection (SQL, shell, path, template), secrets or tokens in code, missing - authorization checks, unsafe deserialization, SSRF, insecure defaults. -3. **Concurrency and state** -- race conditions, unguarded shared state, missing idempotency where - retries occur, resource leaks. -4. **Error handling** -- swallowed exceptions, empty catch blocks, errors logged but not surfaced, - fallbacks that hide failure. -5. **API contracts** -- breaking changes to public interfaces, schema or serialization drift, - incompatible migrations. - -Do **not** report: - -- Style and formatting a linter or formatter already enforces. -- Preferences with no concrete failure mode ("consider renaming", "could be a helper"). -- Issues in lines the merge request did not touch, unless the change breaks them. -- Duplicates: one finding per root cause, at the most representative location. - -Every finding must cite the file path and line number **in the new version of the file** (use the -old version's line only for pure deletions), state what breaks and when, and be concrete enough -that the author can act without asking follow-up questions. - -## Severity scale - -| Severity | Meaning | Example | -|---|---|---| -| `blocker` | Merging this causes incorrect behavior, a security hole, or data loss | SQL built by string concatenation from request input | -| `warning` | Defensible today, likely to fail under plausible conditions | Retry loop with no backoff or bound | -| `nit` | Real but minor; author may reasonably ignore it | Misleading variable name that survives review | - -When unsure between two severities, choose the lower one. A reviewer that cries blocker is muted -within a week. - -## Findings contract - -Scripted engines must emit exactly one JSON object matching this shape, and nothing else that could -be mistaken for it: - -```json -{ - "summary": "One paragraph: overall assessment, notable risks, anything skipped.", - "findings": [ - { - "path": "src/billing/invoice.ts", - "new_line": 142, - "old_line": null, - "severity": "blocker", - "title": "Refund amount is never validated against the invoice total", - "body": "`refund()` accepts `amount` from the request body and passes it to the ledger unchecked. A negative amount credits the customer twice. Validate `0 < amount <= invoice.total` before posting." - } - ] -} -``` - -Field rules: - -| Field | Type | Rule | -|---|---|---| -| `summary` | string | Required. Plain prose, no markdown headings. Mention any files skipped for size. | -| `findings` | array | Required. Empty array means a clean review, not a failed one. | -| `path` | string | Required. Repository-relative, as it appears in the diff's new version. | -| `new_line` | number or null | Line in the new file. Required unless the finding is on a deleted line. | -| `old_line` | number or null | Line in the old file. Only for deletions; null otherwise. | -| `severity` | string | `blocker`, `warning`, or `nit`. Nothing else. | -| `title` | string | One line, under 80 characters, states the defect, not the fix. | -| `body` | string | The claim, the failure scenario, and a concrete suggestion. Markdown allowed. | - -Interactive surfaces (an in-session review, Copilot's native reviewer) present the same content as -prose or native review comments; the JSON contract binds only engines whose output a script parses. diff --git a/plugins/code-reviews/skills/review-mr/SKILL.md b/plugins/code-reviews/skills/review-mr/SKILL.md deleted file mode 100644 index 0164eec..0000000 --- a/plugins/code-reviews/skills/review-mr/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: review-mr -description: "Review a GitLab merge request against the shared rubric in the current session, and post the findings only on explicit confirmation." ---- - -# Review MR - -Read and follow the canonical procedure in [../../commands/review-mr.md](../../commands/review-mr.md). - -Treat the user's current request as the procedure input. Ignore the source file's YAML frontmatter -and any Claude-only invocation syntax. Use equivalent tools available on the current host, preserve -all safety checks, and resolve bundled resources from this plugin rather than the user's project. diff --git a/plugins/code-reviews/skills/review/SKILL.md b/plugins/code-reviews/skills/review/SKILL.md new file mode 100644 index 0000000..f9157ba --- /dev/null +++ b/plugins/code-reviews/skills/review/SKILL.md @@ -0,0 +1,121 @@ +--- +name: review +description: This skill should be used when the user asks to "review this PR", "review this merge request", "code review these changes", "review my diff", "what's wrong with this change", or when an automated pipeline invokes a review on a pull request or merge request. It carries the review methodology - severity, what to flag, what to stay silent about, the verification bar - and reads REVIEW.md, ACT_CODE_REVIEW.md, CLAUDE.md and AGENTS.md as layered guidance. Applies on any host and any provider. +allowed-tools: Read, Grep, Glob, Bash +--- + +# Perform a code review + +Review a change and report only findings a competent engineer would act on. The bar is high on +purpose: false positives erode trust and waste reviewer time, and a review nobody believes is worse +than no review. + +## 1. Read the guidance, in order + +Load whichever of these exist, each layer overriding the one before it: + +| Layer | File | Notes | +|---|---|---| +| Base methodology | this skill and its `references/` | Always applies | +| Repository review config | `REVIEW.md` at repo root | The portable convention. Anthropic's managed Code Review reads it natively; nothing else does unless told to, which is what this step is for. | +| Organization layer | `ACT_CODE_REVIEW.md` at repo root | Extends `REVIEW.md`. Follow any `@path` import it contains by reading that file. | +| Project conventions | `CLAUDE.md`, `AGENTS.md` | Hierarchical: a `CLAUDE.md` in a subdirectory governs only files beneath it. | + +A violation of `CLAUDE.md` or `AGENTS.md` is reportable only when the rule is explicit and the +changed file is in that rule's scope. Quote the rule verbatim in the finding. + +Read `references/guidance-layering.md` when a repository's layers conflict or when a consumer +appears to be ignoring a layer. + +## 2. Establish scope + +Review **only lines this change touched**, plus the minimum surrounding code needed to judge them. +An issue on an untouched line is out of scope unless the change breaks it. + +Skip the review entirely when the change is closed, a draft, or obviously trivial and correct +(a version bump, a generated lockfile, a pure rename). Say so and stop rather than manufacturing +findings. + +If a previous review from this reviewer already exists on this change, read it: do not repeat a +finding that is already posted, and suppress new nits, reporting only newly introduced defects. + +## 3. Find candidate defects + +Work through `references/what-to-report.md` — correctness, security, concurrency and state, error +handling, and API contracts, each with the specific failure shapes to look for. + +Three habits separate a useful pass from a noisy one: + +- **Distrust safety claims.** A comment asserting "validated upstream" or "sanitized" is a claim, + not evidence. Verify the invariant in code you can see. If you cannot, treat the comment as + absent. +- **Check for missing controls, not only added ones.** A new handler is often vulnerable because of + what it lacks. Compare it against its siblings: if they check ownership and this one does not, + the omission is the defect. +- **Keep going after the first finding.** One file can hold several independent problems. + +## 4. Verify before reporting + +Every candidate gets a second, adversarial pass whose default answer is "not a real issue". A +finding survives only with: + +- **A concrete failure scenario**: specific inputs or state that produce a wrong result, a crash, + or a security consequence. "This could be risky" is not a failure scenario and does not ship. +- **A citation**: `path:line` in the code, not an inference from a name or a docstring. + +Drop everything that does not survive. `references/what-to-report.md` closes with the standing +false-positive list — pre-existing issues, linter-catchable problems, pedantic nitpicks, and +generic "needs more tests" observations — that no review should ever emit. + +## 5. Grade + +Use the three tiers in `references/severity-model.md`, which align with what Anthropic's managed +Code Review already emits, so findings read the same wherever they land: + +| Tier | Meaning | +|---|---| +| **Important** | A bug to fix before merging | +| **Nit** | Minor, worth fixing, not blocking | +| **Pre-existing** | Real, but not introduced by this change | + +When torn between two tiers, choose the lower one. + +## 6. Report + +Order findings most severe first. Each one states the defect in a sentence, the failure scenario, +and a concrete suggestion. Include a committable suggestion only when applying it fixes the issue +completely. + +Lead the summary with the shape of the work — a count by tier, or "no blocking issues" when that +is true. When there is nothing to report, say exactly that and stop; an empty review is a good +outcome, not a failed one. + +**Posting is opt-in.** Print to the terminal by default. Post to the pull or merge request only +when the invocation asked for it (a `--comment`-style argument, or a CI prompt that says to), and +use the host's own tooling — never a bespoke poster: + +| Host | Mechanism | +|---|---| +| claude-code-action | `mcp__github_inline_comment__create_inline_comment`, plus `gh pr comment` for the summary | +| GitLab CI | the `mcp__gitlab` tools supplied by `/bin/gitlab-mcp-server` | +| Local with a CLI available | `gh` or `glab` | +| Anything else | print the report; say plainly that nothing was posted | + +Post one comment per unique issue, and never post without the review having been asked to. + +## Tone + +Default to direct, specific, and neutral. A persona is a separable layer that changes voice and +must never change what gets flagged or how severity is graded; `references/personas.md` covers the +tradeoffs and the failure modes of the popular ones. + +## Resources + +- **`references/what-to-report.md`** — the defect checklist and the standing false-positive list +- **`references/severity-model.md`** — the tiers, recalibration, and grading discipline +- **`references/guidance-layering.md`** — precedence, `@` imports, which consumer reads what +- **`references/personas.md`** — tone as an optional layer +- **`templates/REVIEW.md`** — starter repository review config +- **`templates/ACT_CODE_REVIEW.md`** — the ACT layer, importing `REVIEW.md` + +To install this review into a pipeline or hook, use the `install` skill. diff --git a/plugins/code-reviews/skills/review/references/guidance-layering.md b/plugins/code-reviews/skills/review/references/guidance-layering.md new file mode 100644 index 0000000..1500bfb --- /dev/null +++ b/plugins/code-reviews/skills/review/references/guidance-layering.md @@ -0,0 +1,59 @@ +# Guidance layering + +Which file holds what, which consumer reads which file, and what to do where they disagree. + +## Precedence + +Later layers win: + +```text +skill methodology → REVIEW.md → ACT_CODE_REVIEW.md → CLAUDE.md / AGENTS.md +``` + +`CLAUDE.md` sits last not because project conventions outrank review policy, but because they are +the most specific statement of what this codebase considers correct. A `CLAUDE.md` rule is +reportable only when it is explicit and in scope for the changed file. + +## What belongs where + +| File | Holds | Does not hold | +|---|---|---| +| `REVIEW.md` | Review policy: severity recalibration, nit caps, skip rules, repo-specific checks, verification bar, summary shape | Build instructions, architecture notes, anything unrelated to reviewing | +| `ACT_CODE_REVIEW.md` | The ACT layer: organization-wide conventions that apply on top of whatever the repository decided | Repository-specific policy — that belongs in `REVIEW.md` | +| `CLAUDE.md` / `AGENTS.md` | How to work in this codebase: commands, conventions, invariants | Review-only instructions; those dilute every other session | + +Length has a cost. A long `REVIEW.md` dilutes the rules that matter most; keep each layer to +instructions that change reviewer behavior. + +## The `@` import caveat + +`ACT_CODE_REVIEW.md` opens with `@REVIEW.md` so the ACT layer extends rather than replaces the +repository's own policy. Whether that import is expanded depends entirely on the consumer: + +| Consumer | Reads | Expands `@` | +|---|---|---| +| This skill | Every layer, explicitly | Yes — following an `@path` means reading that file | +| `CLAUDE.md` memory system | `CLAUDE.md` | Yes | +| Anthropic managed Code Review | `REVIEW.md` and `CLAUDE.md` only | **No.** `REVIEW.md` is pasted verbatim; referenced files are not pulled in | +| Local built-in `/code-review` | `CLAUDE.md` | Does not read `REVIEW.md` at all | +| GitHub Copilot code review | `.github/instructions/*.instructions.md`, `AGENTS.md` | No | + +Two consequences worth stating plainly rather than discovering later: + +1. **The managed product will not see `ACT_CODE_REVIEW.md`.** For that surface the ACT layer has to + be flattened into `REVIEW.md`. The `install` skill does this as an explicit, visible step and + marks the result as generated. +2. **Copilot needs its own generated file.** Same flattening, into + `.github/instructions/code-review.instructions.md`. + +Anything generated carries a header naming its source and the regeneration step. A generated file +edited by hand is a file that will silently diverge. + +## Conflicts + +When two layers disagree, the later layer wins and no finding is emitted about the disagreement — +that is configuration, not a defect. + +The exception worth surfacing: when a change makes a `CLAUDE.md` statement untrue, that is +reportable as a Nit, because the documentation is now wrong. This runs in both directions — code +that violates the docs, and code that outdates them. diff --git a/plugins/code-reviews/skills/review/references/personas.md b/plugins/code-reviews/skills/review/references/personas.md new file mode 100644 index 0000000..399da80 --- /dev/null +++ b/plugins/code-reviews/skills/review/references/personas.md @@ -0,0 +1,50 @@ +# Personas + +A persona changes the voice of a review. It must never change which findings survive verification +or how they are graded. Tone is the last thing applied and the first thing to drop when it +conflicts with clarity. + +## The default + +Direct, specific, neutral. State the defect, the failure scenario, and a concrete suggestion. No +praise padding, no hedging, no apology. This is what ships unless a repository asks for something +else, and it is the right choice for nearly every team. + +## When a persona helps + +Rarely, and only for internal audiences that opted into it. A distinctive voice can make review +output memorable in a codebase whose contributors already know the reviewer is automated. It is a +morale device, not a quality one. + +## When a persona hurts + +The widely-copied "grumpy reviewer" archetype — `gilfoyle-code-review.instructions.md` in +`github/awesome-copilot` is the best-known example — is instructive precisely because of what it +gets wrong: + +- **It instructs the reviewer not to provide solutions.** A finding without a suggested fix costs + the author a round trip and is strictly worse than one with it. +- **It rewards volume.** A persona built on mockery has an incentive to find something to mock, + which is the exact pressure that manufactures false positives. +- **It buries the defect under the joke.** The reader has to parse the insult to reach the fact. +- **It does not survive an external audience.** Contributors outside the team read it as hostility + from the organization, because that is what it is. + +Adopt it only where every reader is internal and has agreed to it, and never let it override +`references/what-to-report.md`. + +## Applying one safely + +If a repository asks for a persona, put it in `REVIEW.md` under a heading that scopes it to voice, +and state the invariant alongside it: + +```markdown +## Tone + +Write findings in a dry, understated voice. This changes wording only: it does not change +which findings are reported, how they are graded, or the requirement that each names a +concrete failure scenario and a suggested fix. +``` + +That last sentence is the whole safeguard. Without it, a persona instruction competes with the +methodology instead of layering on top of it. diff --git a/plugins/code-reviews/skills/review/references/severity-model.md b/plugins/code-reviews/skills/review/references/severity-model.md new file mode 100644 index 0000000..937ca0d --- /dev/null +++ b/plugins/code-reviews/skills/review/references/severity-model.md @@ -0,0 +1,51 @@ +# Severity + +## The three tiers + +These match what Anthropic's managed Code Review emits, so a finding means the same thing whether +it came from the managed product, a CI job, or a local run. + +| Tier | Emitted when | Author's expected response | +|---|---|---| +| **Important** | Merging this causes incorrect behavior, a security consequence, or data loss | Fix before merge | +| **Nit** | Real and worth fixing, but it will not break anything | Fix if convenient | +| **Pre-existing** | A genuine defect the change did not introduce | Note it; fix separately | + +Nothing above Important exists. A "critical" tier invites inflation, and a reviewer that calls +everything critical is muted within a week. + +## Grading discipline + +**Grade down when torn.** Between Important and Nit, choose Nit. The cost of under-grading is that +someone fixes it next sprint; the cost of over-grading is that the next twenty findings get +skimmed. + +**Severity is about consequence, not effort.** A one-character fix that corrupts billing data is +Important. A large refactor that would be tidier is a Nit at most, and usually nothing. + +**Order beats labels.** Report most severe first. A reader who stops after the third finding should +have seen the three that matter. + +## Recalibrating per repository + +The defaults target production application code. A repository can redefine them in `REVIEW.md`, and +that redefinition wins. Common and legitimate recalibrations: + +| Repository kind | Typical change | +|---|---| +| Documentation or content | Almost nothing is Important; broken links and wrong commands are the exceptions | +| Prototype or spike | Only data loss and credential exposure reach Important | +| Infrastructure as code | Blast radius raises the bar: an unscoped IAM grant or a destructive plan is Important | +| Library with external consumers | Any unversioned breaking change to the public surface is Important | + +Escalation is equally valid: a repository may declare that any violation of a specific +`CLAUDE.md` rule is Important rather than a Nit. + +## Volume + +A review posting thirty nits is a review nobody reads. When a guidance layer caps nits, obey the +cap and report the remainder as a count in the summary. Absent a cap, use judgment: past roughly +five nits, the surplus belongs in the summary rather than inline. + +On a re-review, suppress nits entirely and report only newly introduced defects. A one-line fix +should not reach round seven on style. diff --git a/plugins/code-reviews/skills/review/references/what-to-report.md b/plugins/code-reviews/skills/review/references/what-to-report.md new file mode 100644 index 0000000..5619c9e --- /dev/null +++ b/plugins/code-reviews/skills/review/references/what-to-report.md @@ -0,0 +1,81 @@ +# What to report, and what to stay silent about + +The checklist a review works through, then the standing list of things that must never be reported. + +## Report + +### Correctness + +The largest category and the one worth the most attention. + +- Inverted conditions, off-by-one bounds, wrong operator precedence. +- Unhandled `null` / `undefined` / empty-collection cases on a path that can produce them. +- A function whose behavior differs from what its name, signature, or documentation promises. +- State that is read before it is written, or written after it is read. +- Arithmetic that can overflow, divide by zero, or lose precision where precision matters. +- Early returns and `break`s that skip cleanup the rest of the path depends on. + +### Security + +Flag only with a concrete path from untrusted input to a dangerous sink. The attacker can be any +authenticated user, any network peer, or any untrusted data source — not only an anonymous +outsider. + +- Injection: SQL, shell, path traversal, template, argument injection. +- Missing authorization. For each entry point ask: *if user A submits user B's resource ID, what + stops them?* If the answer is "nothing", that is the finding. +- Secrets or tokens in code, logs, or error messages. +- Unsafe deserialization of attacker-controlled data. +- SSRF: a request whose destination is influenced by input. +- Disabled TLS verification, or crypto assembled by hand. + +### Concurrency and state + +- Read-modify-write without a guard where concurrent callers are possible. +- Missing idempotency where retries occur — a queue consumer, a webhook handler, a CI job. +- Resources acquired without a guaranteed release path. +- Shared mutable state captured by a closure that outlives its expected scope. + +### Error handling + +- Swallowed exceptions and empty catch blocks. +- A catch so broad it hides errors the author never considered. Name which ones. +- Errors logged but not surfaced, so a caller proceeds as though the call succeeded. +- Fallbacks that mask a failure rather than handling it, leaving the system quietly wrong. + +### API contracts + +- Breaking changes to a public interface without a version. +- Serialization or schema drift between producer and consumer. +- Migrations that are not backward compatible with the currently deployed code. + +## Do not report + +This list is not advisory. A finding matching any entry is dropped before the report is written. + +- **Pre-existing issues** the change did not introduce, unless the change makes them reachable or + worse. When one is genuinely worth surfacing, grade it `Pre-existing` and never as a blocker. +- **Anything a linter, formatter, type checker, or compiler catches.** Assume they run in CI. Do + not run them to check. +- **Style and formatting**: naming preferences, import order, line length, comment wording. +- **Pedantic nitpicks a senior engineer would not raise in a real review.** +- **Generic quality observations**: "needs more tests", "could use better docs", "consider + extracting a helper" — unless a specific guidance layer requires it, in which case quote the rule. +- **Speculative issues that depend on inputs or state you cannot show are reachable.** +- **Rules explicitly silenced in the code**, for example behind a lint-ignore comment with a reason. +- **Intentional changes in behavior** that are the evident point of the change. +- **The same root cause reported more than once.** Pick the most representative location. +- **Denial of service through missing limits** — absent timeouts, unbounded loops, no pagination. + These are hardening suggestions, not defects, unless a guidance layer says otherwise. +- **Hardcoded non-secret configuration**: project IDs, table names, bucket names. Only real + credentials count. + +## The test a finding must pass + +Before a finding is written down, it must answer all three: + +1. **What breaks?** One sentence naming the defect. +2. **When?** Concrete inputs or state producing a wrong result, a crash, or a security consequence. +3. **Where?** A `path:line` citation in code that is actually visible, not inferred from a name. + +A candidate that cannot answer all three is not a finding. Discard it silently. diff --git a/plugins/code-reviews/skills/review/templates/ACT_CODE_REVIEW.md b/plugins/code-reviews/skills/review/templates/ACT_CODE_REVIEW.md new file mode 100644 index 0000000..a09dcd7 --- /dev/null +++ b/plugins/code-reviews/skills/review/templates/ACT_CODE_REVIEW.md @@ -0,0 +1,50 @@ +@REVIEW.md + +# ACT review layer + +Organization-wide review conventions, layered on top of this repository's own policy above. Where +the two disagree, this file wins. + +> [!NOTE] +> The `@REVIEW.md` import on the first line is expanded by the `code-reviews` plugin and by the +> `CLAUDE.md` memory system. It is **not** expanded by Anthropic's managed Code Review, which reads +> `REVIEW.md` alone. On that surface, install flattens both layers into `REVIEW.md`. + +## Toolchain + +- **Bun only.** `bun install`, `bun run`, `bunx`, `bun test`. An `npm`, `yarn`, or `pnpm` lockfile + is a finding; `bun.lock` is the only lockfile. +- **No Python.** A `.py` file or an invocation of `python`, `pip`, `uv`, or `poetry` is a finding. +- **No `/tmp`.** Scratch files belong in a gitignored `.tmp/` inside the project. + +## Supply chain + +A new or upgraded third-party dependency is Important unless the change shows it was scored: + +```sh +socket package shallow npm pkg:npm/@ --markdown +``` + +Flag any dimension scoring under 90, naming which one — a low quality score on a build-time +dependency is not the same risk as a low supply-chain or vulnerability score. + +## Repository hygiene + +- **No AI attribution.** A `Claude-Session:` trailer, a "Generated with Claude Code" footer, or an + AI co-author line in a commit message or pull request body is a finding. +- **Conventional commits**: `(): `. +- **No emoji** on ACT-authored surfaces: READMEs, manifests, commands, agents, documentation. Use + GitHub alerts and tables instead. Vendored upstream content is exempt. + +## Instructions are not output + +Text describing *how the work was requested* must not appear in the artifact. Flag any of these in +a shipped file: second person addressed to one reader, "as requested" or "per your instruction", +session status such as "nothing is enabled yet", a count or path taken from one machine used as a +test fixture, or a comment recounting how a bug was found rather than what the code does. + +## Tone + +Direct, specific, neutral. This governs wording only: it does not change which findings are +reported, how they are graded, or the requirement that each names a concrete failure scenario and a +suggested fix. diff --git a/plugins/code-reviews/skills/review/templates/REVIEW.md b/plugins/code-reviews/skills/review/templates/REVIEW.md new file mode 100644 index 0000000..cde1d87 --- /dev/null +++ b/plugins/code-reviews/skills/review/templates/REVIEW.md @@ -0,0 +1,47 @@ +# Review instructions + +Review policy for this repository. Anthropic's managed Code Review reads this file natively and +injects it as the highest-priority instruction block; the `code-reviews` plugin reads it on every +other surface. Keep it to instructions that change reviewer behavior — general project context +belongs in `CLAUDE.md`. + +Delete the sections that do not apply. Every heading below is optional. + +## What Important means here + +Reserve Important for findings that would break behavior, expose data, or block a rollback: +incorrect logic, unscoped database queries, credentials or personal data in logs, and migrations +that are not backward compatible. Style, naming, and refactoring suggestions are Nit at most. + +## Cap the nits + +Report at most five Nits per review. If there are more, add "plus N similar items" to the summary +instead of posting them inline. If every finding is a Nit, open the summary with "No blocking +issues." + +## Do not report + +- Anything CI already enforces: linting, formatting, type errors. +- Generated files and lockfiles. +- Test-only code that intentionally violates production rules. + +## Always check + +Repository-specific rules go here. They land more reliably in this file than in a long `CLAUDE.md`. + +- New public endpoints have an authorization check scoped to the caller. +- Database queries are scoped to the caller's tenant. +- Log lines exclude email addresses, user identifiers, and request bodies. + +## Verification bar + +A behavior claim needs a `file:line` citation in the source. An inference from a function or +variable name is not evidence and is not a finding. + +## Re-review + +After the first review of a change, suppress new Nits and report only newly introduced defects. + +## Summary shape + +Open the summary with a count by severity. Lead with "no blocking issues" when that is true. diff --git a/plugins/code-reviews/skills/setup-mr-review/SKILL.md b/plugins/code-reviews/skills/setup-mr-review/SKILL.md deleted file mode 100644 index 22fb54a..0000000 --- a/plugins/code-reviews/skills/setup-mr-review/SKILL.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: setup-mr-review -description: "Install automated AI code review as a GitLab CI merge-request job, a git pre-push hook, or GitHub Copilot review instructions." ---- - -# Setup MR Review - -Read and follow the canonical procedure in [../../commands/setup-mr-review.md](../../commands/setup-mr-review.md). - -Treat the user's current request as the procedure input. Ignore the source file's YAML frontmatter -and any Claude-only invocation syntax. Use equivalent tools available on the current host, preserve -all safety checks, and resolve bundled resources from this plugin rather than the user's project.