Skip to content

GitHub Actions and other CI

Running the reviewer on a runner rather than from a terminal: the Action and the workflow it goes in, the inputs and permissions it needs, what an incremental review is, what a fork changes, and the same review as a plain CLI call in any other CI. Usage is the same tool locally.

GitHub Actions

name: Review
on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

permissions:
  contents: read
  pull-requests: write

concurrency:
  # A new push supersedes an in-flight review of the same pull request.
  group: nitpick-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # the reviewer needs history to diff against base
      - uses: jdziat/open-nitpick@v1
        with:
          provider: openrouter
          model: z-ai/glm-5.3-flash
          api-key: ${{ secrets.OPENROUTER_API_KEY }}
          # Omit fail-on (or set it to none) until you have seen how the model
          # behaves on your codebase. A reviewer that blocks merges on its first
          # false positive is a reviewer the team switches off.
          fail-on: none
          dry-run: true  # inspect the summary before allowing publication
          skip-drafts: true

The first run is intentionally non-publishing. Inspect the job summary, then remove dry-run: true when the configuration and results are ready to publish.

Talking to it

A comment on the pull request that starts with @open-nitpick is answered by a second workflow on issue_comment and pull_request_review_comment events, running the Action with command: respond:

name: Respond
on:
  issue_comment: {types: [created]}
  pull_request_review_comment: {types: [created]}
permissions: {contents: read, pull-requests: write, issues: write}
jobs:
  respond:
    if: contains(github.event.comment.body, '@open-nitpick')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: {fetch-depth: 0}
      - uses: jdziat/open-nitpick@v1
        with:
          command: respond
          provider: openrouter
          model: z-ai/glm-5.3-flash
          api-key: ${{ secrets.OPENROUTER_API_KEY }}

@open-nitpick review resumes the previous review, or starts one if none exists. @open-nitpick restart-review reviews the whole PR afresh. The older re-review and rereview aliases also restart. @open-nitpick resolve on an inline thread resolves it with a reply naming who asked. @open-nitpick improve runs the wider pass, below. Anything else is a question, answered in the same thread by the review model with the diff, the lines around the thread, and the thread so far as its context; forge-authored text is fenced as untrusted, so a comment cannot instruct the model. The comment gets an eyes reaction when the run starts and a thumbs-up when it has answered. The handle is review.mention in .nitpick.yaml. This repository's own is .github/workflows/nitpick-respond.yml.

Superseded comments

On a later review, an earlier inline comment whose file was rechecked and whose finding did not recur is resolved with a reply saying so, and the walkthrough counts them. review.resolve_superseded: false leaves threads for a person to close.

Skipping a pull request

skip-drafts: true leaves drafts alone until they are marked ready. A pull request that should never be reviewed says so with [skip review] (or [skip nitpick]) in its title, its body, or its head commit's message; the run reports result: skipped, posts nothing, and the job summary says which marker it found. The phrases are review.skip_markers in .nitpick.yaml, so a repository can choose its own.

Posting as your own GitHub App

With the job token the review is posted by github-actions[bot]. A GitHub App has its own identity and repository permissions. Create and install an App with Contents read, Metadata read, and Pull requests read and write; it does not need a webhook. Save its installation ID and private key as repository secrets.

The Action can mint and refresh its own installation tokens. This avoids handing it a one-hour static token, which can expire before a long review publishes:

      - uses: jdziat/open-nitpick@v1
        env:
          NITPICK_APP_ID: ${{ vars.NITPICK_APP_ID }}
        with:
          app-installation-id: ${{ secrets.NITPICK_APP_INSTALLATION_ID }}
          app-private-key: ${{ secrets.NITPICK_APP_PRIVATE_KEY }}
          bot-login: ${{ vars.NITPICK_APP_SLUG }}[bot]
          provider: openrouter
          model: z-ai/glm-5.3-flash
          api-key: ${{ secrets.OPENROUTER_API_KEY }}

Set NITPICK_APP_ID and NITPICK_APP_SLUG as repository variables and both App inputs as secrets. The current action reads NITPICK_APP_ID to enable its refreshable App credentials, and the example builds bot-login from NITPICK_APP_SLUG. bot-login must be the App slug followed by [bot], and lets the reviewer recognize its own earlier work. Use the default github-token instead when an App identity is not needed. The job’s permissions still control the default token; the App token uses the App’s installation permissions.

Inputs

Every input the Action takes, with its default. The examples above set the ones most runs need; the rest are here because reading action.yml should not be the way to find out that the configuration file can live somewhere other than the repository root.

input default what it does
bot-login github-actions[bot] account whose earlier reviews may be reused; use <app-slug>[bot] with App credentials
github-token ${{ github.token }} reads the pull request and publishes the review. Needs pull-requests: write; fork tokens cannot publish
app-installation-id none GitHub App installation ID. With app-private-key and NITPICK_APP_ID in the calling step environment, mints replacement tokens before expiry during a long run
app-private-key none GitHub App private key. Set only with app-installation-id, as a secret
config .nitpick.yaml configuration path relative to the workspace
provider config overrides models.default.provider; nitpick providers lists providers
model config overrides models.default.model
api-key none provider key; pass a secret, never a literal
fail-on review.fail_on lowest severity that fails: nit, info, warning, error, critical, or none
instruction none one extra instruction for this run
pr-number triggering pull request set for workflow_dispatch and comment events
dry-run false writes to log and job summary without publishing
skip-drafts false skips draft pull requests
version latest release tag or latest; falls back to building the Action checkout when its release is unavailable
analyzers empty catalog names to install, comma separated; auto selects ones for changed languages; empty uses runner tools
command review review, fast-review, or respond
args none flags passed to review and fast-review

What a run does

The release binary for the runner is downloaded and its checksum verified; when no release matches version, or the repository is a private copy whose releases cannot be fetched, the binary is built from the Action's own checkout. The pull request is reviewed and the review is posted as one GitHub review with inline comments. The same review (walkthrough, findings table, analyzer roster) is written to the job summary, and the step sets outputs a later step can read:

output value
result clean, findings, skipped, or error. A completeness failure is error, not a clean review.
findings number of findings published
practice_findings practice-report findings, including advisory findings and accepted exceptions; may overlap inline findings
practice_blocking engineering-policy violations without an accepted exception or unresolved uncertainty
critical, error, warning findings at each severity
files files reviewed
withheld findings not republished because an earlier review already posted them
complete whether the selected policy’s required assessment completed
failed_stages comma-separated model stages that did not complete; empty when none did

Exit codes: 0 clean, 1 findings at or above fail_on, 2 the review could not run. CI can tell "this change has problems" apart from "the reviewer broke", and result says which without parsing the log.

Analyzers on the runner

A stock runner has none of the deterministic analyzers installed, so the roster says "not on PATH" for every one. Set analyzers: auto and the Action installs and caches, at whatever version each tool's own installer serves that day, the analyzers for the languages the change touches: golangci-lint for Go, ruff and pylint for Python, shellcheck, hadolint, yamllint, actionlint and zizmor, tflint and checkov, sqlfluff, rubocop and brakeman, cppcheck, biome, buf, and gitleaks always. A comma-separated list installs exactly those. The installs are not pinned: they run go install ...@latest, package-manager installs, "latest" release downloads, and for actionlint, tflint and dotenv-linter the vendor's install script fetched from its default branch, inside a job that holds the model key and a write-scoped GITHUB_TOKEN. That is the same exposure as any workflow that installs tools from upstream, and it is the reason the roster names the tool and version it ran; pin by preinstalling the tools you trust and listing them, or leave analyzers unset. This is what the hosted reviewers do implicitly; here it is a line in the workflow.

Try it first

dry-run: true prints the review to the log and the job summary and posts nothing. nitpick explain-config shows the resolved configuration for a path without running a model.

Events

On pull_request the pull request is found from the environment. On workflow_dispatch or issue_comment, set pr-number. On push there is no pull request: the pushed range is reviewed and printed to the log and the summary, nothing is posted, and fail-on still gates the job; fetch-depth: 0 is required so the range is in the checkout. Any other event fails with a message rather than reviewing an empty tree.

Forks

On a public repository a pull_request from a fork is not reviewed at all. GitHub withholds every secret except GITHUB_TOKEN from such a run, so the api-key input arrives empty and the run exits 2 with "API key is required". It gets as far as parsing the diff first, which is why the log shows the files it read and then a credential error rather than a diff error.

A related rule, not the same one, is why the workflow in this repository skips dependabot[bot]: those runs read from a separate Dependabot secret store, so they start with no model credential and fail the same way.

Where a key is present, on a private repository or a self-hosted setup that supplies one, the remaining limit is the token: GITHUB_TOKEN is read-only on a fork run, so the review cannot be posted. It is not lost there. The run prints it to the log, writes it to the job summary, gates on it, and emits a warning annotation saying why it was not posted.

Do not switch to pull_request_target to get either of these back. That runs the workflow with your secrets against the fork's code, and this tool's own trust model is not a substitute for that mistake. There is no arrangement that reviews an untrusted fork with a credential the fork cannot reach, and saying so is more use than a workaround that leaks the key.

Incremental review

A review reuses successful model requests from its latest published review when the prompts, code and related context, model settings, and resolved policy still match. Failed requests retry. New commits rerun affected batches; unchanged batches keep their results. Changing one file can invalidate its whole batch or another batch that includes it as context.

A push is also scoped before batching: only the files that changed since the last reviewed head are read, widened to include any file still carrying an unresolved comment from an earlier run, so that comment can still be verified or closed. A file that neither changed nor carries a standing comment is left alone; its earlier findings still stand. Scoping falls back to the whole change whenever the comparison itself is unavailable: no earlier head, no provider that can answer it, or a force push that made the earlier head unreachable.

Analyzers, triage, validation, and approval run again over the combined results. Earlier findings still count, and unresolved threads still prevent approval. The summary reports how many model requests were reused. review.incremental: false disables reuse; nitpick review -full and @open-nitpick restart-review bypass it for one run.

Progress is stored in the review on GitHub, so a new Actions runner can resume. Legacy reviews without these records start fresh. Records are bounded to fit GitHub's review body limit; omitted results are reviewed again. A run that never publishes a review does not save new progress.

How a finding is recognised as already posted: every comment carries a fingerprint of its path, class and title, and a review carries the revision it read. A finding matches an earlier comment when the fingerprints agree (which survives the line moving), or when the class and file agree and the line is within two of where the forge now shows the earlier comment, which survives a rewording. Deleting the bot's comment is how a reviewer says "do not post this again"; it will not come back.

Pinning

@v1 follows the latest 1.x release of the Action; the binary it installs follows version, which defaults to the latest release. Pin both to a tag for a build that never changes under you. GitHub Enterprise Server is supported: the API URL comes from the runner; releases are fetched from github.com.

A pinned binary and a .nitpick.yaml written for a newer one is the mismatch worth knowing about: a key the pinned build does not have fails the run before the diff is read. Set NITPICK_IGNORE_UNKNOWN_KEYS=1 on that workflow to ignore those keys and continue; see A key this nitpick does not have.

Any other CI

nitpick review -owner acme -repo-name widgets -pr 42

with GITHUB_TOKEN in the environment. Inside GitHub Actions the repository and pull request number are detected automatically.

Review identity and retries

The Action's bot-login input identifies the account whose review history may be reused. For an App token, set it to the app slug followed by [bot], as the App example above does with ${{ vars.NITPICK_APP_SLUG }}[bot]. For the CLI, set NITPICK_BOT_LOGIN; personal tokens can leave it unset to resolve their authenticated user. A comment's HTML markers alone establish no identity.

Only completed reviews carrying the completion marker can be reused. Legacy reviews and failed runs trigger a full review. While earlier findings remain, the files that carry them are rechecked alongside whatever changed; confirmed findings still affect fail_on even when their comments are not posted twice. Strict analyzer failures exit 2.

On merge_group, the Action reviews the event's base/head SHAs as a local range. Both commits must be available in the checkout; use fetch-depth: 0.