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: synthetic
          model: hf:moonshotai/Kimi-K3
          api-key: ${{ secrets.SYNTHETIC_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
          skip-drafts: true

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: synthetic
          model: hf:moonshotai/Kimi-K3
          api-key: ${{ secrets.SYNTHETIC_API_KEY }}

@open-nitpick review reviews the whole change again, not the increment. @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 gives it a name and an avatar of your choosing, its own permissions, and reviews that count as a reviewer's in the pull request sidebar. Create one under your account or organisation (Settings, Developer settings, GitHub Apps): no webhook, repository permissions Contents read, Metadata read, Pull requests read and write; install it on the repositories to review; generate a private key. Save the App ID as a repository secret NITPICK_APP_ID and the key file's contents as a secret NITPICK_APP_PRIVATE_KEY (GitHub refuses secret names that start with GITHUB_). Then mint the token in the job and hand it to the Action:

    env:
      NITPICK_APP_ID: ${{ secrets.NITPICK_APP_ID }}   # a step's if cannot read secrets, only env
    steps:
      - name: Token for the review app
        id: app
        if: env.NITPICK_APP_ID != ''
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ env.NITPICK_APP_ID }}
          private-key: ${{ secrets.NITPICK_APP_PRIVATE_KEY }}
      - uses: jdziat/open-nitpick@v1
        with:
          github-token: ${{ steps.app.outputs.token || github.token }}

The if and the || make the App optional: a repository without the secret posts with the job token as before. The token the step mints lasts an hour and is scoped to the installation, so the workflow's permissions block still governs only the default token.

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
github-token ${{ github.token }} reads the pull request and publishes the review. Needs pull-requests: write. On a pull request from a fork it is read-only, so nothing is posted; see Forks for what a fork run does and does not do
config .nitpick.yaml path to the configuration file, relative to the workspace
provider from the config file overrides models.default.provider; nitpick providers prints the list
model from the config file overrides models.default.model
bot-login github-actions[bot] trusted posting account; set <app-slug>[bot] for an App token, or the account name for a personal token
api-key none the provider's key. Pass a secret, never a literal. Ignored where the provider reads its own conventional variable
fail-on from review.fail_on lowest severity that fails the job: nit, info, warning, error, critical, or none
instruction none one extra instruction, applied to this run only
pr-number the triggering pull request set it on workflow_dispatch and issue_comment runs
dry-run false print the review to the log and the job summary instead of publishing it
skip-drafts false do nothing on a draft pull request
version latest a release tag such as v1.0.0, or latest. See the paragraph below for what happens when no release matches
analyzers empty catalog names to install on the runner before the review, comma separated, or auto for the ones the change's languages call for. Empty installs nothing and the review runs whatever the runner already has
command review review reviews the pull request; respond answers a comment that mentioned the reviewer
args none flags passed verbatim to nitpick review, for anything with no input of its own

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 (the gate was tripped), skipped (a draft), or error (the review could not run)
findings, critical, error, warning counts of what was published
files files reviewed
withheld findings an earlier review had already posted

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 prompt a path would get before a token is spent.

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

After a completed review with no standing findings, a later push is reviewed incrementally: only files changed since that review are read. While earlier findings remain, the whole change is rechecked. Confirmed findings affect the failure gate even when their comments are withheld from reposting. The review says which files it read and how many findings it withheld. A force push that makes the earlier revision unreachable reviews the whole change again. review.incremental: false reviews the whole change on every push.

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], for example ${{ format('{0}[bot]', steps.app.outputs.app-slug) }}. 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 whole change is rechecked; 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.