Skip to content
gopaytechPublic

About

Agentic PR/MR review tool for CI/CD pipelines.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Repository files navigation

pergent

pergent

Agentic merge request reviews in your CI pipeline, powered by opencode.

License Go Image

Quick start · Skills · Agent mode · Result schema


Agentic PR/MR review tool for CI/CD pipelines. Unlike single-shot review tools, pergent spawns opencode as a subprocess that autonomously loops through the repository -- reading files, following references, grepping for patterns -- to build deep context before writing its review.

✨ Features

  • Agentic review: opencode reads files, greps and follows references in the repository before it writes a finding, instead of judging the diff alone.
  • Skills: review behaviour lives in Markdown skill files, each with its own scope (paths, exclude) and its own idea of what is blocking.
  • Any size of change: changed files are split into batches that fit the model's context, and every skill and batch runs in parallel under one time budget.
  • Structured findings and verdicts: each skill ends no_blocking_issues, has_blocking_issues or failed; blocking findings are re-checked by a separate run before they count.
  • Honest failures: a timeout, context overflow or provider error is reported with its cause, never as a pass, and the CI job stays green.
  • Read-only sandbox: the agent can only read, grep, glob and list; edits, shell, web access and .env reads are denied, and secrets are redacted from everything pergent prints.
  • One comment, updated: GitHub and GitLab get a single review comment that is edited on every push instead of piling up; --previous-review gives the model the last review's findings as context.
  • Agent mode: pergent agent reviews a prepared checkout with trusted inputs only and delivers a versioned JSON result, for running pergent as a service.

🔁 How it works

  1. You define review behavior via skill files (.md files with review instructions)
  2. For each skill, pergent picks the changed files in its scope, groups them into batches that fit the model's context, and spawns one opencode run per batch, attaching that batch's diff
  3. The agent explores the repo iteratively (reads files, greps, follows references) and answers with structured findings
  4. pergent merges the findings, re-checks the blocking ones, decides a verdict per skill, and posts one combined review comment on the PR/MR

Skills and batches run in parallel (see --concurrency), all within one time budget for the whole review (see --timeout).

A run gets only its own batch's diff attached. So that a run can see how another changed file changed (a skill with paths, or a batch of a big change, would otherwise miss a problem spanning two areas), pergent writes the diff of each reviewable changed file to its own temporary file, plus an index of them, once per review. Nothing is attached: a run that does not cover every such file is told in its message where the index is (batch, cross-batch and verification runs alike), and it can read the index and then the diffs it needs. A run may read them; nothing proves it does. Only files that at least one skill reviews, with no default exclusion, whose path the read rules do not deny (*.env, *.env.* except *.env.example, and in agent mode *.pem, *.key, *.tfvars) and whose diff fits in a batch, are listed. If the files cannot be written, pergent logs a warning and the review runs without them. When you use opencode's own config (no OPENCODE_PROVIDER), nothing is written and no run is told about diffs, because only pergent's generated config lets a run read outside the repository.

The agent also picks up project context from the repo root's AGENTS.md (or CLAUDE.md if there is no AGENTS.md). The file may be a symlink, but only to a regular file inside the checkout that the read rules allow (CLAUDE.md -> AGENTS.md is fine). A link that leaves the checkout, does not resolve, or points at a file the read rules deny (such as prod.env) is skipped with a warning naming it, and the next name is tried.

Agent sandbox

When OPENCODE_PROVIDER is set, pergent writes a temporary opencode.json for each opencode run and points opencode at it with OPENCODE_CONFIG. That config:

  • defines a pergent agent that carries the skill's prompt (its body plus pergent's output-format instructions) and --max-turns as its step limit, and makes it the default agent (pergent also passes --agent pergent). The prompt is written to a file next to opencode.json (mode 0600, removed with it) and the config names the file with {file:...}, because opencode expands {env:VAR} and {file:path} anywhere in a config's text: a skill that mentions {env:GITLAB_TOKEN} or {file:missing} reaches the model exactly as written
  • allows only read, glob, grep and list, and denies edit (which covers file writes and patches), bash, task, skill, webfetch and websearch
  • denies reading *.env / *.env.* files (except *.env.example)
  • turns off language servers (lsp: false), because opencode's read tool starts them whatever the permissions say, and a server can be a program from the reviewed branch (such as node_modules/.bin/biome) running with your LLM key in its environment
  • turns off opencode's auto-compaction (compaction.auto: false), so a run that fills the model's context fails with the overflow error instead of continuing from a summary
  • leaves the model's context and output limits to opencode's model registry, unless you set --context-limit

pergent also sets OPENCODE_DISABLE_PROJECT_CONFIG=true, so an opencode.json or .opencode/ directory (agents, plugins, commands) in the reviewed branch can't change these permissions or run plugin code while your LLM key is in the environment. GITHUB_TOKEN and GITLAB_TOKEN are removed from opencode's environment: only pergent uses them. Because that setting also stops opencode from finding AGENTS.md / CLAUDE.md by itself, pergent passes the root file to opencode explicitly.

opencode checks the path it is asked to read, not where a symlink leads, so a link in the reviewed branch could expose a file beyond the checkout (x -> /etc/passwd, x -> ..). In platform mode (GitHub or GitLab CI), before any run, pergent deletes every symlink in the checkout whose target resolves outside it, or whose real target the read rules deny (safe.txt -> prod.env; the rules match the name being read, not where a link leads), and logs each path. .git is skipped. A link that dangles counts as leaving unless its target can be shown to stay inside. The link is deleted, never its target, and the review goes on; a link that cannot be deleted, or a directory that cannot be read, is a warning. --local never deletes anything: it reviews your own working tree and trusts the checkout. (Agent mode: see --scrub-instructions.)

Each opencode run also gets its own empty XDG_DATA_HOME, removed when the run ends. The agent can read only the tool-output folder in it, where opencode saves a long tool result, not the rest (your auth.json and the session database). opencode creates its SQLite database there on first start, and runs started at the same moment in one fresh directory (a new CI container) race on that setup. The losers exit before the model's first step. Your auth.json, if you have one, is copied in, so opencode auth login still works.

🩺 When a review fails

Each opencode run (one per batch) ends in one of two ways: the model's own final answer, in the required format, is parsed into findings, or the batch is reported as Review failed: with the cause and the files it covered. pergent never posts opencode's internal messages or the model's in-progress text ("Let me check...") as a review. A skill with any failed batch has the verdict failed, never "no blocking issues", even if the other batches found nothing.

Cause Example
Context overflow The model's context window overflowed after 14 steps: the last completed step used 186,400 tokens (limit: the model's context window as known to opencode). ContextOverflowError: prompt is too long: 201234 tokens > 200000 maximum.
Timeout Timed out after 10m0s (review budget 10m0s) during step 9; the last tool call was read on service/store.go.
opencode error opencode reported APIError (HTTP 429): Rate limit exceeded. Gave up after 4 attempts.
Step limit Reached the 20-step limit before the model finished the review (raise --max-turns to allow more steps).
opencode exited non-zero opencode exited with code 1 at step 2 without producing a review. (The last lines of opencode's stderr follow in a code block.)
No answer opencode exited normally after 6 steps, but the model did not produce a final answer (last step finished with reason "tool-calls").
  • The CI job stays green when a review fails; the cause is in the comment and the job log. Configuration errors found before any review starts (missing token, bad flag) still fail the job.
  • Transient provider errors (HTTP 429, 5xx, connection errors) are retried up to 3 times with exponential backoff (5s, 10s, 20s) within the review's --timeout budget. opencode already retries these itself before giving up, so pergent's retries cover longer outages. If every attempt fails, the last error is reported as is.
  • Step limit: on the last allowed step opencode asks the model to stop and summarise its progress. That summary is not a review, so it is reported as a step-limit failure. If the model ignores the instruction and keeps calling tools, pergent stops opencode.
  • An answer in the wrong format (no JSON block, or a block that does not parse) gets one repair run that rewrites it; if that fails too, the batch is reported as failed with an excerpt of the answer.
  • Secrets are redacted from the comment and the log: the values of OPENCODE_API_KEY, GITHUB_TOKEN and GITLAB_TOKEN, bearer tokens, and API keys in URLs.

📦 Installation

Docker (recommended for CI)

docker pull ghcr.io/gopaytech/pergent:latest

The image is built for linux/amd64 and linux/arm64 (from v0.1.2); Docker pulls the one that matches the host. It ships opencode, git and ripgrep (opencode's glob and grep tools need rg; from v0.1.3).

Build from source

git clone https://github.com/gopaytech/pergent.git
cd pergent
make build
# Binary at ./bin/pergent

Requires opencode installed on the system.

A build from source reports its version as dev; only a release image carries a real one. pergent.sh treats dev as older than any release, so such an image gets full reviews.

🚀 Usage

Local Mode

Run reviews locally and print output to stdout. No platform token or PR required.

Prerequisites:

  • opencode installed
  • A git repo with commits ahead of the base branch

Setup LLM (option A -- configure via env vars):

export OPENCODE_PROVIDER=anthropic          # or openai, litellm, etc.
export OPENCODE_API_KEY=sk-your-api-key     # your LLM API key
export OPENCODE_MODEL=claude-sonnet-4       # model to use

Setup LLM (option B -- use opencode's existing config):

If you already have opencode configured (e.g., GitHub Copilot, custom opencode.json), leave OPENCODE_PROVIDER (and OPENCODE_MODEL) unset. pergent will use opencode's own config. In this mode pergent does not generate a config, so the agent sandbox above does not apply: opencode runs with whatever agents and permissions your config defines. pergent attaches the skill's instructions (with the output format) to each run as a file review-instructions.md, because they would otherwise live only in the config it did not write.

  • With --local this needs nothing more: it is your own machine. OPENCODE_MODEL without OPENCODE_PROVIDER is ignored and logs a warning.
  • In GitHub or GitLab CI it needs an opt-in: set PERGENT_USE_OPENCODE_CONFIG=true. Without it, an empty OPENCODE_PROVIDER is a config error (an unset CI variable expands to an empty one, and the review would silently lose its sandbox). The error also names OPENCODE_MODEL when that is set without a provider.
  • This mode has no sandbox, so do not use it on untrusted changes. The project config is on, so the change's own AGENTS.md and opencode.json (agents, plugins, permissions) apply, opencode may run commands, and GITHUB_TOKEN / GITLAB_TOKEN stay in opencode's environment because it may sign in with one. A pull request from a fork or an outside contributor can use that. pergent logs a warning on every such run. For untrusted changes, set OPENCODE_PROVIDER so pergent runs the review in its read-only sandbox.

Test the connection:

pergent --test

Run:

# Using a preset skill
pergent --local --skill code-review

# Using a custom skill file
pergent --local --skill ./my-skills/custom-review.md

# Custom base branch (default: main)
pergent --local --base-branch develop --skill code-review

# Multiple skills
pergent --local --skill code-review --skill ./my-skills/security-check.md

GitLab CI

mr-review:
  stage: review
  image: ghcr.io/gopaytech/pergent:latest
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - pergent --skill code-review
  variables:
    GITLAB_TOKEN: $GITLAB_TOKEN
    OPENCODE_PROVIDER: anthropic
    OPENCODE_API_KEY: $ANTHROPIC_API_KEY
    OPENCODE_MODEL: claude-sonnet-4

GitHub Actions

name: PR Review
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  review:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/gopaytech/pergent:latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Run pergent
        run: pergent --skill code-review
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          OPENCODE_PROVIDER: anthropic
          OPENCODE_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          OPENCODE_MODEL: claude-sonnet-4

Custom LLM (LiteLLM, vLLM, Ollama)

pergent works with any OpenAI-compatible API. Set OPENCODE_BASE_URL to your endpoint and pergent automatically uses /chat/completions instead of /responses:

export OPENCODE_PROVIDER=openai
export OPENCODE_API_KEY=your-key
export OPENCODE_MODEL=your-model
export OPENCODE_BASE_URL=https://your-proxy.example.com/v1

pergent --local --skill code-review

🧩 Skills

Skills are markdown files that define review behavior. The --skill flag accepts either a preset name or a file path.

Preset Skills

Built-in skills embedded in the binary:

Name Description
code-review General code quality, bugs, clarity, performance, security
pergent --skill code-review

Custom Skills

Create your own .md file with optional YAML frontmatter:

---
name: security-review
---

You are a security engineer reviewing a pull request.
Focus on authentication, authorization, injection vulnerabilities,
and sensitive data exposure.
...
pergent --skill ./skills/security-review.md

Scope: paths, exclude and cross_batch

The frontmatter can narrow which changed files a skill reviews and ask for an extra check across batches:

---
name: api-review
paths:
  - "api/**"
  - "*.proto"
exclude:
  - "**/*_test.go"
cross_batch: true
---
Field Meaning
paths Glob patterns. When set, the skill reviews only changed files matching one of them. Files that match none are listed in the comment's Files list as skipped, out of scope. Unset means every changed file.
exclude Glob patterns for files the skill skips even though they are in scope.
cross_batch true adds one extra run, after all batches, that looks for problems spanning batches (for example conflicting changes in different directories). It only runs when the change needed more than one batch. Default false.

Patterns use /-separated paths from the repo root. * matches within one path segment and ** matches any number of segments. A pattern with no / (such as *.proto) matches the file name at any depth. A pattern with bad glob syntax, or one that can never match a file (api/, /api/**, ./api/**), is an error when the skill loads, because the skill would otherwise report a clean review on every change. For a directory, use api/**.

Default exclusions

Every skill skips these files, and the comment's Files list shows them as skipped, with the reason:

  • Always skipped: deleted files, binary files, and files with no content changes (for example a pure rename).
  • Skipped unless a skill's paths match them: lockfiles (package-lock.json, go.sum, yarn.lock, pnpm-lock.yaml, Cargo.lock, composer.lock, Gemfile.lock, poetry.lock, Pipfile.lock), vendored code (anything under a vendor, node_modules or third_party directory), and generated files (paths marked linguist-generated in the repo's .gitattributes, unless a later matching line unsets it with -linguist-generated, linguist-generated=false or !linguist-generated, or files whose first lines say the code is generated and not to be edited).

A file larger than a batch can hold (about a quarter of the model's context window, 40,000 tokens when the window is unknown) is not reviewed and is reported as a failure for that file.

The ## Blocking section

A review has two kinds of findings: blocking ones, which decide the verdict, and suggestions. Tell the skill what blocks by adding a ## Blocking heading (any level) to its body and listing the problems that must be fixed before merging:

## Blocking

- Logic errors that cause wrong behaviour, crashes, or data loss.
- Security vulnerabilities.

Everything else is a suggestion.

When a skill has no ## Blocking section, pergent appends a default one: bugs, security vulnerabilities and broken configuration are blocking, and everything else is a suggestion. Before a blocking finding is reported, pergent runs a separate check that opens the code again and drops the finding if the problem is not real.

Checklists become checks by area

If a skill contains a checklist (section headings with items under them, as bullets or checkboxes), the model reports every item as a check: pass, fail or n/a, with the evidence it compared. The comment shows the checks by area: each checklist heading is a heading in the comment with a small table of Check, Status and Detail, failed checks first. No special syntax is needed; write the checklist as you would for a human reviewer. The ## Blocking section is not treated as a checklist. Findings still decide the verdict: a failed check should come with a finding, and the comment notes it when one doesn't.

Don't put output-format instructions in skills

pergent appends its own output-format instructions to every skill: the model must end its answer with one JSON block holding a short summary of what it checked and concluded, its checks (the skill's checklist, if it has one), and its findings. The comment shows the summary, and the agent result carries it too (summaries). A missing summary is not an error; the review still counts. Don't tell a skill how to format its answer (no "use bullet points", "start with a summary", or JSON schemas of your own). Instructions like that conflict with the required format and make runs fail. Write what to look for and what counts as blocking; pergent handles the rest.

💬 The review comment

pergent posts one comment per PR/MR, updated in place on each run. It leads with the result, the model's summary and the findings; the checks follow, by area. In order:

  • a headline with the verdict over all skills (✅ No blocking issues, ❌ N blocking issues, or ⚠️ N skills failed; a clean review that has dismissed blocking findings reads No blocking issues (2 dismissed)) and, when a skill carries a model score, the confidence (· Confidence 3/5, or · Confidence not scored when the skill that carried the score failed)
  • a small line of facts: the reviewed commit (first 7 characters), the number of files, N of M checks passed, and the finding counts, ending with · N skills failed when there are blocking findings and a skill also failed
  • the model's reason for the score, as a one-sentence quote, only when the score is below 5 or capped
  • a skills table (skill, verdict, confidence, findings), only when more than one skill ran
  • failures, one ⚠️ Review failed: line each, with the cause, and the files that were not reviewed when the skill had several batches or several failures
  • the summary, when one skill ran in one batch and the model gave a summary: **Summary:** …, 1 to 3 sentences. With several batches, one line per batch (- **Batch 1 of 3:** …) sits at the top of the checks instead, because the number of batches is not limited. With several skills, the summaries are in the skill sections below
  • the findings, one list across all skills: blocking findings first and open, suggestions collapsed. Each row shows the title, file:line linked to those lines, and the skill name when there are several. Open a row for the explanation, the suggested fix and, for a small fix, the exact replacement code (Suggested change, replacing lines 42–43:). The model gives replacement code for at most 10 lines; a suggestion that doesn't fit is dropped and the finding is kept
  • the dismissed findings, collapsed, when someone set any aside
  • the details: with several skills, one #### <skill> section for each skill that has a summary or checks, in the order of the skills table, holding its summary and then its checks. The checks, when a skill has a checklist, are by area: a #### <area> heading (##### <area> inside a skill section) with a table of Check, Status and Detail. Areas keep the order they first appear in, and failed checks come first in their area, so one failure among many is not missed. Checks the model gave no area come last; beside named areas they get the heading Other checks, so they don't read as part of the area above, and with no named areas they have no heading. A check that several batches answered is one row, with the worst status
  • one collapsed Files (N) list of every changed file, with the reason for each file a skill skipped or did not review
  • notes, such as a skill with no files in scope, a verification that was skipped, a failed check that has no matching finding, or the findings the model left out because people rejected the same mistake before (Left out because of past dismissals: Missing nil check (Caller never passes nil), at most 20 listed)
  • a footer with the pergent version
  • the hidden findings block (last line)

A skill with failed batches still shows the findings from the batches that finished, next to the failure.

A review with one blocking finding looks like this:

### ❌ 1 blocking issue · Confidence 3/5 (capped)

<sub>Commit `4f1c2a9` · 4 files · 2 of 3 checks passed · 1 blocking · 1 suggestion</sub>

> The retry loop in `internal/retry/retry.go` is new and untested. (capped: 1 blocking finding)

**Summary:** Read the new retry package and its two callers. The loop never checks for a cancelled context.

#### Findings (2)

▸ ❌ **Retry never stops on a cancelled context** · internal/retry/retry.go:42–47
▸ 💡 **Name the backoff constant** · internal/retry/backoff.go:9

#### Errors

| Check | Status | Detail |
|---|---|---|
| Context cancellation is honoured | ❌ | Do loops on ctx.Err() without returning |
| Backoff is capped | ✅ | maxDelay is applied in next |

#### Tests

| Check | Status | Detail |
|---|---|---|
| The failure path is tested | ✅ | TestDoGivesUp covers it |

▸ Files (4)

pergent v0.3.0

(In the real comment each ▸ row is a collapsed <details> block, and the blocking one is open.)

The confidence is the model's own judgement of how safe the change is to merge, from what it reviewed. It is the lowest score among a skill's finished batches, and pergent caps it: a blocking finding caps it at 3, and a file skipped as deleted, binary, vendored, a lockfile or generated caps it at 4. A skill that failed is not scored. It is information only: it never changes a verdict. When no skill carries a model score (older answers, failed runs), the comment shows no confidence at all.

Links. In GitHub CI mode, file:line links to the file at the pull request's head commit (pull_request.head.sha); in GitLab CI mode, at CI_MERGE_REQUEST_SOURCE_BRANCH_SHA (set in merged-results pipelines) or else CI_COMMIT_SHA. A line link ends in ?plain=1#L<line>, so the anchor works for Markdown and other rendered files too. Local and agent mode have no links. actions/checkout checks out the merge commit of a pull request, so if the base branch changed the same file above a finding since the branch was cut, the line anchor on GitHub can be a few lines off.

Dismissing a finding. With pergent.sh, each finding also shows a ready-to-copy dismiss command with its ID, and a dismissed finding moves to the Dismissed list. The CLI's CI comment shows no finding IDs or dismiss command. Agent mode shows each finding's ID in its row (id …), and pergent.sh adds the dismiss command.

The last line of the comment is a hidden HTML comment holding the findings for the next run (see below). Don't edit it. Secrets are redacted from the comment and from that block.

A comment that would exceed the platform's size limit (65,536 characters on GitHub, 1,000,000 on GitLab) is shrunk to fit rather than failing to post. In order: the hidden findings block is dropped (the next --previous-review run is then a first review), then the suggestions' explanations and fixes (their titles stay), then the suggestions and dismissed findings, then the passed and n/a checks (an area left with none loses its heading), then the failed checks (the counts in the facts line stay), then the Files list is cut to 20 paths and then to a count, and last the summaries. The headline, facts line, reason, skills table, blocking findings and failures are always kept; each step leaves a note in the comment. As a last resort, if the blocking findings and failures alone exceed the limit, the comment is cut at the limit with a note.

Layouts

The order above is the default. A program that builds the comment with the public render package can choose another one with render.Options.Layout, a list of blocks: summary, findings, dismissed, checks and files, each open or collapsed (ParseLayout reads findings:collapsed-style entries). A block the list leaves out is not written; findings is required. summary:collapsed puts the summaries in a Summary fold, checks:collapsed keeps the failed checks in view and folds the passed and n/a ones per skill (Checks: 3 of 4 passed, 1 n/a), and findings:open opens the suggestions too. The summaries by batch or skill, and the failed checks, sit where checks is, else where summary is, else right after findings. A list that is empty or not valid gives the default.

A layout changes what the comment shows, never what the review decides. Everything above the blocks (the headline, facts line, reason, skills table and failures) and below them (notes, footer, hidden block) stays, blocking findings are always open, a failed check is always shown (only the size limit can cut failed checks, at its step 5, with a note), and a quiet review (Options.Short) still leaves out the Files list and the passed checks. A clean headline still says (2 dismissed) when the dismissed block is left out. The size-limit steps run in the order above whatever the layout says, and a block left out leaves no note (failed checks cut for size still do). The last-resort cut, when the blocking findings and failures alone are over the limit, is made on the default order, so a custom order can't push a blocking finding out first. The CLI always uses the default layout.

📝 The change's title and description

In CI mode pergent reads the pull request's or merge request's title and description and attaches them to each batch run and the cross-batch run as change-description.md, so the model knows what the author meant to do. The log says change description: <n> bytes when they are read. If they can't be read, a warning is logged and the review goes on without them. --local has no change to read them from. In agent mode the caller passes them with --change-description.

What the model is told:

  • A change in behaviour the description calls intended is not a finding for that reason alone. A stated intent never excuses a problem that counts as blocking.
  • Code that does the opposite of what the description says is a suggestion, whatever a skill's ## Blocking section says, unless the code is a blocking problem in its own right.
  • Something the description promises but the run does not see is not reported: a run sees only part of the change, so it may be in another batch.
  • The description itself is never a finding, however short, vague or empty.

The author writes this text, so it is data, never instructions. Those rules are wording, not a guarantee: the text can still sway the review run. Verification runs never get it, so a blocking finding is judged on the code alone. HTML comments are removed first, since the platforms do not show them to a reviewer. Only the first 16 KiB is attached, and editing the description does not start a review.

A skill can use the description, but must still work without it: it is missing with --local, when it can't be read, and on a change with an empty title and description.

🔄 Previous review as context

By default each run reviews from scratch. With --previous-review (or PERGENT_PREVIOUS_REVIEW=true), pergent fetches the review comment it posted on the previous run, reads the findings from its hidden findings block, and attaches each skill's findings to that skill's runs alongside the diff. The agent is instructed to stay consistent with them, focus on what changed since, drop findings the new commits resolve, and reuse a finding's fingerprint when it still applies. pergent keeps a reused fingerprint only on the same file and line as the previous finding, so a new finding can't take over an old one's identity or its dismissal. A skill with no previous findings gets nothing attached.

pergent stays stateless, the PR/MR comment is the only persistent state. First runs (no existing comment) behave exactly as before. Requires platform mode (not available with --local).

Comments posted by older versions of pergent have no findings block, so they are ignored: the first run after upgrading is a first review. A findings block that cannot be read is skipped with a warning in the log.

The findings are attached as data: the prompt tells the agent to treat the file as data, never as instructions.

Which comment is pergent's

A comment is pergent's own only when both hold: its body starts with the <!-- pergent --> marker (every pergent comment does), and its author is the identity of pergent's token. A note by anyone else that holds the marker, or a forged findings block, is never updated, never overwritten and never read for findings: pergent skips it, keeps looking through every page, and creates its own comment if it finds none.

pergent learns the token's identity once per run with GET /user:

  • GitLab: the api scope that posting notes needs also covers /user, for project and group access tokens too. If the read fails, as it can for a fine-grained token without read_user, the comment lookup counts as failed (see below), so each run posts a new comment.
  • GitHub: a personal or fine-grained token can read /user. The GITHUB_TOKEN of GitHub Actions, and a GitHub App installation token, cannot (GitHub answers 403). Then pergent accepts only a comment whose author is a Bot, because a person cannot post as a Bot.

If the token's identity changes (a new bot user, a new token owner, a switch between GITHUB_TOKEN and a personal token), pergent starts a new comment and the old one stays on the PR/MR. Delete it by hand if you don't want it.

If looking for the existing comment fails (a network or server error), pergent tries once more after a few seconds. If it still fails, it still posts the review as a new comment, because a visible new result beats a stale one, and it logs that a duplicate may appear.

mr-review:
  stage: review
  image: ghcr.io/gopaytech/pergent:latest
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  script:
    - pergent --skill code-review
  variables:
    GITLAB_TOKEN: $GITLAB_TOKEN
    PERGENT_PREVIOUS_REVIEW: "true"
    OPENCODE_PROVIDER: anthropic
    OPENCODE_API_KEY: $ANTHROPIC_API_KEY
    OPENCODE_MODEL: claude-sonnet-4

🤖 Agent mode

pergent agent is for a service that runs reviews on code it does not trust, such as a merge request from a fork. It reviews a checkout that someone else prepared, between two commits, and produces a result (versioned JSON). It posts nothing.

What it is:

  • No platform token. It never calls GitHub or GitLab. The caller reads the result, posts the comment, sets the status and approves.
  • Trusted inputs only. Skills, project instructions and the previous result come from paths you pass, never from the checkout: the checkout is the change under review, and a change could rewrite its own review rules. The parts of the default skip rules a change could rewrite come from the base commit: .gitattributes and generated-file headers, so a change can't mark its own files as generated or binary to hide them. lockfile and vendored are fixed path rules, and binary and deleted follow the change itself. A skill's verdict covers only the files it reviewed: the caller's approval policy must decide what skipped files mean (see What a verdict covers).
  • A result for every outcome. A review where every skill failed is still a result (exit 0). A review that could not run is an error result with a code and a ready-to-post comment.

What it isn't: it does not clone or fetch (the caller prepares the checkout), approve, post comments or set statuses. CI mode (pergent --skill ...) is unchanged.

pergent agent \
  --repo-path /work/repo \
  --base 4f1c2a9e8b7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b \
  --head 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b \
  --skills-dir /in/skills \
  --instructions /in/instructions.md \
  --scrub-instructions \
  --result result.json

stdout carries only the result, and only with --result -; all logs go to stderr, with each progress line prefixed by the skill and batch ([code-review 2/3]).

Agent flags

Flag Description Default
--repo-path The checkout of the head commit. Must be the top of the git work tree required
--base The change's merge base: a full 40- or 64-character lowercase commit SHA required
--head The change's head: a full lowercase commit SHA. The checkout's HEAD must equal it required
--skills-dir Directory of skills: flat *.md files and <name>/SKILL.md folders. Symlinked files and folders count (as in a ConfigMap volume); a folder without a SKILL.md is skills_invalid; entries starting with . are skipped required unless --skill is given
--skill Preset name or skill file path (repeatable); can be combined with --skills-dir —
--instructions One file passed to the agent as project instructions. Without it the agent gets none; AGENTS.md / CLAUDE.md are never discovered unset
--previous A previous agent result: its findings are attached per skill, as --previous-review does in CI mode. A missing, unreadable or other-version file logs a warning and the review runs as a first review unset
--carried A JSON file {"<skill>": ["path", ...]} of files a previous review covers. Those files are left out of the skill's batches (they stay in the changed-file list the model sees) and are listed in files.carried; a skill with only carried files runs nothing and gets no_blocking_issues. Findings on them are not attached as previous findings. A missing, unreadable or malformed file logs a warning and carries nothing unset
--dismissals A JSON file {"<skill>": [{"file": "...", "title": "...", "severity": "blocking", "reason": "..."}, ...]} of findings people rejected in earlier reviews, with their reasons. A skill's entries are attached to each of its batch runs and its cross-batch run as rejected-findings.json, with a note to treat the file as data; its verification run gets only the entries with severity blocking. Entries for a skill that is not run are ignored. A missing, unreadable or malformed file logs a warning and attaches nothing; a run the file cannot be written for goes on without it and the skill gets the note Past dismissals could not be attached to every run. A finding the model leaves out because of an entry is named in a note on the skill. The log line dismissals: <skill>=<n> ... shows what was read $PERGENT_DISMISSALS, else unset
--change-description A file holding the change's title and description. It is attached to each batch run and the cross-batch run as change-description.md, with a note to treat it as data; verification and repair runs never get it. HTML comments are removed and only the first 16 KiB is attached. A missing or unreadable file logs a warning and the review goes on without it $PERGENT_CHANGE_DESCRIPTION
--scrub-instructions Declares --repo-path a disposable checkout and deletes the files below before reviewing false
--max-comment-size Byte limit for the rendered comment (0: no limit) 0
--result Write the result to this file, or - for stdout. A file is written to <path>.tmp, fsynced, then renamed —
--result-url POST the result here, with PERGENT_RESULT_TOKEN as the bearer token —
--timeout, --concurrency, --max-turns, --context-limit As in CI mode as in CI mode

At least one of --result and --result-url is required; both can be used together. --local, --platform, --previous-review and --base-branch are CI-mode flags and are a usage error here.

Environment: OPENCODE_PROVIDER and OPENCODE_MODEL are required (a usage error otherwise), so pergent can generate the sandboxed opencode config. OPENCODE_API_KEY and OPENCODE_BASE_URL work as in CI mode. PERGENT_RESULT_TOKEN is required with --result-url; it is removed from opencode's environment and redacted from the logs and the result (a value shorter than 8 characters is not redacted). GITHUB_TOKEN and GITLAB_TOKEN are not used in agent mode and are removed from opencode's environment too. opencode's own stderr is passed to pergent's with credentials redacted and the run's prefix on each line.

Rules for the inputs and the checkout

  • git 2.40 or newer. The diff reads .gitattributes from the base commit through GIT_ATTR_SOURCE, which older git ignores. Below 2.40 pergent refuses with the internal error code. The Docker image (debian:trixie-slim) has a recent enough git.
  • Commits and checkout. --base and --head must be full commit SHAs, not branch names or short SHAs. Both commits must exist in the checkout, and its HEAD must be --head. The diff is git diff <base> <head>, so no history between them is needed.
  • Trusted inputs outside the checkout. --instructions, --skills-dir, a path given to --skill, --previous, --carried, --dismissals and --change-description must not resolve inside --repo-path (a usage error). Nor may a --result file (the change could plant a symlink at its .tmp file; the error result is not written there), $HOME or $XDG_CONFIG_HOME, from which opencode loads its global config and plugins whatever the project-config switch says. Duplicate skill names are a usage error too: the caller decides which skill wins.
  • Instruction files and symlinks. opencode loads a nested AGENTS.md, CLAUDE.md or CONTEXT.md whenever the agent reads a file next to it, and no setting turns that off, so a change could plant instructions there. A symlink pointing outside the checkout would let the read-only agent read files beyond it, and so would a symlink inside the checkout to a file the read rules deny (safe.txt -> prod.env): the rules match the name being read, not where the link leads. Pass --scrub-instructions and pergent deletes every file with those names (any case, the root included, .git skipped), every symlink whose target resolves outside the checkout (a dangling one counts as outside unless its target can be shown to stay inside) and every symlink whose real target the read rules deny (agent mode's rules, so *.pem, *.key and *.tfvars too), and logs each path. Without the flag, pergent refuses with untrusted_instructions when such a file exists below the root, when a symlink resolves to the checkout root, when a symlink resolves outside the checkout, or when a symlink's real target is a file the read rules deny. A directory pergent cannot read is an internal error: it could hold a link the scan did not see. A service that reviews untrusted changes should always pass the flag. Changes to these files still show up in the diff and are reviewed as content.
  • Secrets in the checkout. In agent mode the agent is also denied reading *.pem, *.key and *.tfvars, on top of *.env / *.env.*. The checkout must not hold credentials in .git/config, which the agent can read: see the init container below.

Exit codes

Code Meaning
0 A result was produced and delivered, even if every skill failed.
2 Usage error. An error result is delivered only when a delivery target could be parsed.
3 No review was possible (for example a missing commit or an untrusted checkout). The error result was delivered.
4 A delivery failed.

HTTP delivery never follows redirects. A 409 counts as delivered (the receiver already has the result); 401, 403, 413 and other 4xx replies fail without a retry; 429, 5xx and connection errors are retried after 2s, 4s and 8s. Each request times out after 30 seconds, which counts as a connection error. The full table is in the result schema.

After SIGTERM or SIGINT, pergent stops its opencode runs and fails the unfinished skills with a cause (cancelled, or stopped at the Job deadline if --timeout had already run out). A run already in flight gets the cause cancelled with the same reason, never exit_error. pergent still delivers a completed result. Delivery then gets 20 seconds with one retry after 1s, so it fits Kubernetes' 30-second grace period.

Running as a Kubernetes Job

A Job prepares the checkout in an init container and runs pergent in the main container. The two get different secrets: the init container holds only a read-only git token, and pergent holds the LLM key and the result token, never a platform token.

apiVersion: batch/v1
kind: Job
metadata:
  name: review-7f3a9c
spec:
  backoffLimit: 0
  # --timeout (10m) + 2 minutes to finish and deliver + allowance for the clone
  activeDeadlineSeconds: 900
  template:
    spec:
      restartPolicy: Never
      terminationGracePeriodSeconds: 30
      volumes:
        - name: work
          emptyDir: {}
        - name: inputs
          configMap:
            name: review-7f3a9c-inputs
            # ConfigMap keys can't contain "/", so items are required to put
            # each skill under skills/. List every key.
            items:
              - key: instructions.md
                path: instructions.md
              - key: code-review.md
                path: skills/code-review.md
      initContainers:
        - name: checkout
          image: ghcr.io/gopaytech/pergent:latest
          command:
            - sh
            - -ec
            - |
              # The token goes through git's environment config, so it is never
              # in the remote URL or written to .git/config. Use the username your git
              # host expects (x-access-token is GitHub's).
              export GIT_CONFIG_COUNT=1
              export GIT_CONFIG_KEY_0=http.extraHeader
              export GIT_CONFIG_VALUE_0="Authorization: Basic $(printf 'x-access-token:%s' "$GIT_TOKEN" | base64 -w0)"
              git init -q /work/repo
              cd /work/repo
              git remote add origin "$REPO_URL"
              git fetch -q --depth=1 origin "$BASE_SHA" "$HEAD_SHA"
              git checkout -q --detach "$HEAD_SHA"
          env:
            - name: REPO_URL
              value: https://git.example.com/acme/widgets.git
            - name: BASE_SHA
              value: 4f1c2a9e8b7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b
            - name: HEAD_SHA
              value: 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b
            - name: GIT_TOKEN
              valueFrom:
                secretKeyRef:
                  name: review-7f3a9c-git
                  key: token
          volumeMounts:
            - name: work
              mountPath: /work
      containers:
        - name: pergent
          image: ghcr.io/gopaytech/pergent:latest
          command:
            - pergent
            - agent
            - --repo-path=/work/repo
            - --base=4f1c2a9e8b7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b
            - --head=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b
            - --skills-dir=/in/skills
            - --instructions=/in/instructions.md
            - --scrub-instructions
            - --timeout=10m
            - --result-url=https://reviews.example.com/runs/7f3a9c/result
          env:
            - name: OPENCODE_PROVIDER
              value: anthropic
            - name: OPENCODE_MODEL
              value: claude-sonnet-4
            - name: OPENCODE_API_KEY
              valueFrom:
                secretKeyRef:
                  name: review-7f3a9c-llm
                  key: api-key
            - name: PERGENT_RESULT_TOKEN
              valueFrom:
                secretKeyRef:
                  name: review-7f3a9c-llm
                  key: result-token
          volumeMounts:
            - name: work
              mountPath: /work
            - name: inputs
              mountPath: /in
              readOnly: true

Notes:

  • activeDeadlineSeconds counts from the Job's start, so image pulls and the clone use it up. Keep it above --timeout plus time to finish and deliver, so pergent's own budget always ends first.
  • The Job's deadline and a cancelled Job both send SIGTERM; terminationGracePeriodSeconds: 30 is what lets pergent deliver afterwards.
  • If the init container and the pergent container run as different users, git refuses the checkout as "dubious ownership" and pergent reports checkout_missing. Run both as the same user, or add safe.directory for the checkout (for example GIT_CONFIG_COUNT=1, GIT_CONFIG_KEY_0=safe.directory, GIT_CONFIG_VALUE_0=/work/repo in the pergent container's environment).
  • Fetching a commit by SHA depends on the git host allowing it. If the merge base can't be fetched that way, fetch the target branch with a larger --depth instead; pergent reports commit_missing when a commit is absent.
  • A crash, an out-of-memory kill or a deadline kill delivers no result. The service must read the Job's status for those.

The result's fields, error codes and examples are in docs/agent-result-schema.md.

⚙️ CLI flags

Flag Description Default
--skill Preset name or path to skill .md file (repeatable) required
--local Local mode: diff from git, output to stdout false
--base-branch Base branch for local git diff main
--test Test opencode connection (sends a hello prompt) false
--previous-review Attach the findings from pergent's previous review comment as context on re-runs (platform mode only) false
--platform github or gitlab auto-detect
--max-turns Max agentic turns per opencode run 20
--context-limit Model context window in tokens. Only for models opencode has no limits for (e.g. behind a custom OPENCODE_BASE_URL); otherwise opencode's model registry is used unset
--max-tokens Deprecated and ignored. It used to set the context window, which made opencode compact far below the model's real limit. Prints a warning —
--timeout Breaking: max wall-clock time for the whole review, all skills, batches, retries and checks together. It used to apply to each skill run separately, so a job that relied on a per-skill limit now shares one budget 10m
--concurrency Max opencode runs in flight at once, across all skills and batches. Must be at least 1 4
--repo-path Path to repo root .

🌱 Environment variables

LLM (passed through to opencode)

Variable Description
OPENCODE_PROVIDER LLM provider (anthropic, openai, etc.). Required in CI unless PERGENT_USE_OPENCODE_CONFIG=true
OPENCODE_API_KEY API key for the LLM provider
OPENCODE_MODEL Model to use (e.g., claude-sonnet-4). Ignored, with a warning or an error, when OPENCODE_PROVIDER is empty
OPENCODE_BASE_URL Custom API base URL (for LiteLLM, vLLM, Ollama, etc.)
OPENCODE_NPM Override AI SDK package (default: @ai-sdk/openai-compatible when OPENCODE_BASE_URL is set)

Platform

Variable Description
GITHUB_TOKEN GitHub API access token
GITLAB_TOKEN GitLab API access token

Limits (alternative to flags)

Variable Description
PERGENT_MAX_TURNS Max agentic turns per opencode run
PERGENT_CONTEXT_LIMIT Model context window in tokens (see --context-limit)
PERGENT_MAX_TOKENS Deprecated and ignored (see --max-tokens)
PERGENT_TIMEOUT Max wall-clock time for the whole review (see --timeout; breaking: it used to be per skill run)
PERGENT_CONCURRENCY Max opencode runs in flight at once (see --concurrency)
PERGENT_PREVIOUS_REVIEW true/false — attach the previous review's findings as context (explicit --previous-review flag wins over this)

Own-config mode

Variable Description
PERGENT_USE_OPENCODE_CONFIG true/false — in GitHub/GitLab CI, allow a review with an empty OPENCODE_PROVIDER on opencode's own config. No sandbox: do not use on untrusted changes (see option B). Not needed with --local; agent mode always needs a provider

Config resolution priority: CLI flags > PERGENT_* env vars > auto-detect from CI > defaults.

An unset or empty variable means the default. A value that can't be used is a config error that names the variable and the value (PERGENT_TIMEOUT="600" is not a duration such as 90s or 10m), not a silent fall back to the default. Numbers must be whole (PERGENT_MAX_TURNS and PERGENT_CONCURRENCY at least 1, PERGENT_CONTEXT_LIMIT not negative), PERGENT_TIMEOUT needs a unit, and PERGENT_PREVIOUS_REVIEW and PERGENT_USE_OPENCODE_CONFIG are true or false. --max-turns below 1 is rejected as well. A flag that is passed wins, and its variable is not read.

🔍 Troubleshooting

opencode seems slow or stuck

pergent shows step-by-step progress as opencode works. Skills and batches run in parallel, so lines from different runs interleave:

  Step 1: starting...
  Step 1: tool read
  Step 1: done (472 tokens)
  Step 2: starting...
  Step 2: generating text...
  Step 2: done (800 tokens)

If no steps appear, the model may be slow to respond. Test the connection:

pergent --test

If --test also hangs, test opencode directly:

opencode run "Say hello"

If that also hangs, the issue is your LLM connection, not pergent.

Review failed: ...

The message is the cause (see When a review fails). To see everything opencode reported, dump its raw output:

PERGENT_DEBUG_OUTPUT=/tmp/pergent-raw.json pergent --local --skill code-review
cat /tmp/pergent-raw.*.json

This shows the raw NDJSON events. Every opencode run (and every retry of one) writes its own file, named from your path with a number before the extension (/tmp/pergent-raw.1.json, /tmp/pergent-raw.2.json, ...), and pergent logs each path as it writes it. A file that exists is never written over. The files are readable by you alone (mode 600), and credentials such as the API key are redacted. Treat them as private anyway: they hold the code and the review. The review is the text of the last step_start...step_finish block whose step_finish has a reason other than tool-calls. "type":"error" events carry opencode's error, and text events with "synthetic":true are opencode's own messages, never the model's.

git diff failed: exit status 128

Common causes:

  • Wrong base branch: your repo uses master not main. Use --base-branch master.
  • No git repo: run git init and make at least one commit.
  • No remote: origin/main doesn't exist. Push to a remote or create the ref.

/responses API errors

If you see errors mentioning /responses: Invalid model name or 404s from /v1/responses, your API proxy doesn't support OpenAI's Responses API. pergent handles this automatically when OPENCODE_BASE_URL is set — it uses @ai-sdk/openai-compatible which calls /chat/completions instead. Make sure OPENCODE_BASE_URL ends with /v1:

export OPENCODE_BASE_URL=https://your-proxy.example.com/v1

The model's context window overflowed

The diff, skill instructions and the files the agent read no longer fit in the model's context. Either:

  • Use a model with a larger context window
  • Lower --max-turns, so the agent reads less before answering
  • If opencode doesn't know your model's limits (custom OPENCODE_BASE_URL), set --context-limit to the real window

Configuration is invalid from opencode

If you set OPENCODE_PROVIDER, pergent generates a config for opencode. If the generated config doesn't match your opencode version, unset the provider env vars and let opencode use its own config:

unset OPENCODE_PROVIDER OPENCODE_MODEL OPENCODE_BASE_URL OPENCODE_API_KEY
pergent --local --skill code-review

This is for your own machine. In GitHub or GitLab CI the same step needs PERGENT_USE_OPENCODE_CONFIG=true, and then the review has no sandbox (see below).

Using opencode's existing config (GitHub Copilot, etc.)

If you already have opencode configured (e.g., connected to GitHub Copilot), leave OPENCODE_PROVIDER unset. pergent will skip config generation and opencode uses its own config:

pergent --local --skill code-review

In GitHub or GitLab CI, set PERGENT_USE_OPENCODE_CONFIG=true as well. Without it pergent stops with OPENCODE_PROVIDER is not set. That mode has no sandbox, so do not use it on untrusted changes (see option B).

OPENCODE_PROVIDER is not set in CI

In GitHub or GitLab CI, an empty OPENCODE_PROVIDER stops the review, so a CI variable that was never defined (it expands to an empty string) can't quietly turn the sandbox off. Set OPENCODE_PROVIDER and OPENCODE_MODEL (and OPENCODE_API_KEY), or, if you want opencode's own config and trust every change under review, set PERGENT_USE_OPENCODE_CONFIG=true. If the error also says OPENCODE_MODEL is set without OPENCODE_PROVIDER, the provider variable is the one that is missing.

Ctrl+C to cancel

Pressing Ctrl+C cleanly stops both pergent and the opencode subprocess.

⚖️ How it differs from pr-agent

pergent pr-agent
Review approach Agentic loop -- reads files, greps, follows references Single API call with static diff
Context Builds context iteratively, on-demand Entire diff sent in one prompt
LLM support Any provider via opencode Specific provider integrations
Customization Skill files (.md) Configuration flags
Small context models Works with 128k models (reads on-demand) Needs large context for big diffs

📄 License

MIT. See LICENSE.

About

Agentic PR/MR review tool for CI/CD pipelines.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages