ocra

GitHub pull requests

Review pull requests with the GitHub Action or ocra review --pr.

GitHub Action

Add a workflow to the repository you want reviewed:

name: ocra
on: pull_request

permissions:
  contents: read
  pull-requests: write

# One review per pull request at a time: a new push cancels the older run.
concurrency:
  group: ocra-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - uses: jma49/Open-CR-Agent@c2fda45c42880d4c8223b185704f7598c889f831 # v0.4.0
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

The Action is pinned to the commit of the v0.4.0 release, with the version as a comment. A tag can be moved, and a commit fixes the exact code that runs with your model key; git ls-remote --tags https://github.com/jma49/Open-CR-Agent v0.3.0 prints a release's commit, and Dependabot updates such pins and their comment.

From v0.1.1, the Action installs ocra from npm: the @open-cr-agent/cli published at the Action's own version, with every dependency at the version the Action's package-lock.json pins, install scripts off and, on the npm registry, the registry's signatures checked. It uses them only when each of ocra's own packages has provenance showing it was built by this repository's release workflow from the version's tag; otherwise it builds its own source, with a warning. npm never changes a published version. While a version is not on npm yet, in a release's first minutes, the Action builds its own source instead; v0.1.0 always does. So @main runs the Action code on main with the release main names, not unreleased package code, unless main has changed the packages' dependencies since that release: then it builds main.

Keep fetch-depth: 0: ocra needs the history between the base and the head, and a shallow checkout stops with a message saying so. The concurrency group matters too: without it, two quick pushes run two reviews that post the same inline comments, and the slower one can overwrite the summary with an older head. A cancelled run stops, keeps its partial report in the job log and publishes nothing (the action forwards the cancellation to ocra; if it arrives while ocra is already publishing, publishing completes). ocra retries GitHub's rate limits and short outages a few times; it never repeats a request that may have posted a comment already.

Store your model key as a repository secret. Models come from the base branch's .ocra/config.json, or from OCRA_MODEL_* variables in env.

InputDefaultMeaning
github-token${{ github.token }}Token for reading the pull request and posting the review
argsExtra ocra review options, such as --reviewers correctness,security, split on spaces and newlines (no quoting)
fail-on-concernsfalseFail the job when the verdict is significant_concerns (a critical finding the verifier confirmed). An incomplete review (exit code 3) always fails the job
opencodetrueInstall OpenCode, the default runtime. Set false when the configuration sets "runtime": "direct"; see The runtime. New in v0.3.0
sariffalseAlso write the review as SARIF 2.1.0 for code scanning, to the file the sarif output names. It takes the place of --format and --output in args

Give the step an id to read its outputs. They are set whenever ocra ran, also when the step fails:

OutputMeaning
verdictapproved, approved_with_comments, minor_issues or significant_concerns
exit-codeocra's exit code, before fail-on-concerns turns 1 into a passing step
run-idThe run id, also in the summary comment and the SARIF log
reportPath to the JSON report, the shape of --format json, under $RUNNER_TEMP
findingsThe number of findings in the report
sarifPath to the SARIF log, when sarif is true

verdict is empty unless the review completed (exit code 0 or 1): a run that could not review everything (exit 2 or 3) may still write a report, but its verdict says nothing about the unreviewed files. run-id, report and findings are empty when ocra wrote no report. For example, to keep the report and say the verdict in the job summary:

      - id: ocra
        uses: jma49/Open-CR-Agent@c2fda45c42880d4c8223b185704f7598c889f831 # v0.4.0
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
      - if: ${{ !cancelled() && steps.ocra.outputs.report != '' }}
        uses: actions/upload-artifact@v7
        with:
          name: ocra-report
          path: ${{ steps.ocra.outputs.report }}
      - if: ${{ !cancelled() }}
        env:
          VERDICT: ${{ steps.ocra.outputs.verdict }}
          FINDINGS: ${{ steps.ocra.outputs.findings }}
          RUN_ID: ${{ steps.ocra.outputs.run-id }}
        run: echo "ocra: $VERDICT, $FINDINGS findings (run $RUN_ID)" >> "$GITHUB_STEP_SUMMARY"

Pass outputs to a script through env, as here, rather than writing ${{ }} into run.

The verdict is advice. The models read the pull request, so its author can plant text that steers a reviewer away from an issue or talks the judge out of one, and an unverified model claim can be wrong. Do not make the check a required status in place of human review; if you turn on fail-on-concerns, treat a pass as "nothing confirmed", not "safe".

Pull requests from forks get no secrets on pull_request, so their review stops with a message naming the missing key. To review them, see Pull requests from forks.

The runtime

The Action installs OpenCode, the default runtime: with its native binary for the runner's platform, it is 97 of the 108 packages the Action installs and about 165 MB of 175 MB. When every model in the base branch's configuration is on an endpoint you declare and that configuration sets "runtime": "direct" (Choosing the runtime), set opencode: false: the install leaves OpenCode out and takes 11 packages. Should the configuration then select opencode, the review stops before any model call with exit code 2, naming the missing package. The input is new in v0.3.0.

      - uses: jma49/Open-CR-Agent@<commit> # v0.4.0
        with:
          opencode: false
        env:
          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}

The install is not cached between runs: it uses an npm cache of its own each time, so every package, signature and provenance statement comes from the registry, never from a cache another workflow of the repository could have written.

What gets posted

  • Inline comments for findings anchored inside the diff, each marked verified, unverified or not verified, in one review with the COMMENT event. ocra never approves. Set github.requestChanges to request changes when the verdict is significant_concerns: ocra requests them once, and withdraws (dismisses) its own request when a later run's verdict no longer blocks or the commit is overridden.
  • One summary comment, updated in place on every push: verdict, summary, findings outside the diff, what was fixed since the last review, and coverage, cost and the run id. When files were not reviewed, it says how many, and whether the spend limit was reached.

On the next push ocra reviews only what changed: the files changed since the commit the previous review covered, plus any file that review did not finish (a failed task, or one the spend limit kept from starting). Findings in unchanged files carry over as they were: still open, still counted in the verdict, not commented again. Everything is reviewed again when the previous commit is no longer part of the branch (force-push or rebase), when the push raises the risk tier (reviewers that start at the higher tier have not seen the unchanged files yet), when the summary comment was last edited by someone other than ocra (ocra then also ignores what that comment remembers, though it still recognizes its own inline comments nobody edited and does not post them again), or when you pass --full (for example in the action's args). Changing which reviewers run (the base branch's reviewers, --reviewers, --ultra) does not by itself review unchanged files again; pass --full once when you do. The summary says which it was and why.

ocra then compares findings with the previous review:

  • A finding it already commented on is not commented again.
  • A finding counts as fixed only when the code it pointed at is no longer in the file, or the file was deleted. Fixed findings are listed and their inline threads resolved.
  • A finding that no reviewer reported this time, while its code is unchanged, is listed as not reported this time. Model runs vary, so it stays open: its thread is not resolved, it is not commented again, and it keeps counting towards the verdict with its original severity until the code changes or a reviewer dismisses it. The same code reviewed twice gets the same verdict.
  • A finding whose file was not reviewed this time (a failed task, for example) is listed as not re-checked and also stays open.

Reviewers stay in charge. If someone with write access to the repository (admin, maintain or write permission) other than the pull request's author resolves one of ocra's threads, or replies /ocra dismiss, or replies with a message that opens with a clear decline ("won't fix", "will not fix", "by design", "false positive", "not a bug", "working as intended", "intended behavior") and is not a question, the finding counts as dismissed: it is no longer reported and no longer affects the verdict, unless a later run finds it with a higher severity. The same words elsewhere in a reply ("this is not intended, good catch", "is this by design?") do not count, and neither does "I disagree". Such a reply from someone who could dismiss the finding is shown to the judge whenever the finding is reported again; the judge may drop the finding when the reply gives a specific reason it is wrong, never a confirmed critical finding. The author's own replies are not shown. The author cannot dismiss findings on their own pull request, and a reply someone else edited does not count (anyone with write access can edit any comment), so the check cannot be resolved away. Only findings with an inline thread can be dismissed this way; for others use ocra memory.

Code scanning

To see the findings as code scanning alerts as well, write SARIF with the sarif input and upload it with GitHub's upload-sarif action. Add a permission and a few lines to the workflow above:

permissions:
  contents: read
  pull-requests: write
  security-events: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - id: ocra
        uses: jma49/Open-CR-Agent@c2fda45c42880d4c8223b185704f7598c889f831 # v0.4.0
        with:
          sarif: true
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
      - uses: github/codeql-action/upload-sarif@v4
        # Also after an incomplete review, which fails the step above.
        if: ${{ !cancelled() && steps.ocra.outputs.sarif != '' }}
        with:
          sarif_file: ${{ steps.ocra.outputs.sarif }}
          category: ocra
  • The review is still posted to the pull request, and the report output still names the JSON report: SARIF only changes what ocra prints. args: --format sarif --output ocra.sarif does the same with a path of your choosing.
  • Code scanning shows only results with lines. Findings ocra could not tie to lines are in the file but not shown there. So are findings from an earlier review in files not changed since: after the first review of a pull request, ocra reviews only what changed. For code scanning to show every finding on every push, add --full to args, at the cost of a full review each time.
  • Code scanning is available for public repositories; private ones need GitHub Code Security.
  • What the file holds: SARIF output.

Importing an analyzer's results

The same job can hand ocra the results of Semgrep, CodeQL or another analyzer that writes SARIF: run the tool first and pass its log with --import-sarif. Only its results on the change are kept, and they are verified and judged with ocra's own findings (SARIF input). ocra runs no tool itself.

      - run: pipx run semgrep scan --config p/default --metrics=off --sarif --output semgrep.sarif
      - uses: jma49/Open-CR-Agent@c2fda45c42880d4c8223b185704f7598c889f831 # v0.4.0
        with:
          args: --import-sarif semgrep.sarif
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

The import is tested against a log Semgrep 1.178.0 wrote; this workflow has not been run end to end in CI yet.

Overriding a blocking verdict

When ocra blocks a change you have decided to merge anyway, someone with write access other than the pull request's author can comment:

/ocra override <full head commit id> <reason>

For that commit the run exits 0 instead of 1, so fail-on-concerns passes, and a requestChanges review is withdrawn; the verdict itself stays significant_concerns, and the summary shows who overrode it and why (it also prints the command with the commit id to copy). A new push needs a new override. The full commit id is required because a short prefix can be matched by a new commit made for the purpose. Write access is checked through the repository's permissions, the author cannot override their own pull request, a command someone else edited into a comment does not count, and an incomplete review (exit 3) is not overridden.

Someone with push access can push a workflow of their own, which gets the repository's secrets and can post as github-actions[bot]; with on: pull_request, even the review's workflow file is their branch's copy. For a review they can neither change nor impersonate, use the setup below.

What the pull request can and cannot change

The pull request is untrusted input. ocra reads .ocra/config.json, .ocra/rules.json, .ocra/memory.json and AGENTS.md from the base commit, never from the pull request, and never loads repository plugins in this mode. The diff, title and description still reach the reviewers as data, with the protections described in Security.

Pull requests from forks

On pull_request, a pull request from a fork runs without your repository's secrets and with a read-only token. To review such pull requests, run the Action on pull_request_target instead. That event runs the workflow file of your default branch, with your secrets and the permissions you grant. This workflow replaces the one above and reviews your own branches too:

name: ocra
on:
  pull_request_target:
    types: [opened, synchronize, reopened, labeled]

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: ocra-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    # Members and collaborators: every push. Anyone else: one review each
    # time a maintainer adds the ocra-review label.
    if: >-
      (github.event.action == 'labeled' && github.event.label.name == 'ocra-review') ||
      (github.event.action != 'labeled' &&
        contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association))
    runs-on: ubuntu-latest
    steps:
      # The base branch. Nothing from the pull request is checked out.
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: jma49/Open-CR-Agent@c2fda45c42880d4c8223b185704f7598c889f831 # v0.4.0
        with:
          args: --max-cost-usd 2
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

GitHub warns against pull_request_target because it gives a job your secrets while someone else controls the pull request: a job that checks out and builds the pull request's code runs that code with your secrets. ocra runs nothing from the pull request. It fetches the pull request's commits and reads them as data, takes configuration, rules and guidelines from the base commit, and never loads repository plugins (see Threat model). The workflow around it has to keep it that way:

  • Never check out, install, build or test the pull request in this workflow: no ref: ${{ github.event.pull_request.head.sha }}, no dependency install, no test run. Run those in a separate pull_request workflow, which gets no secrets for forks.
  • Keep the if: gate. Anyone can open a pull request, and every review costs money. With the gate, an outsider's pull request is reviewed once each time a maintainer adds the label; remove the label and add it again for the next review. --max-cost-usd caps each review.
  • Use a model key that serves only these reviews, with a spending limit at the provider.
  • Pin the Action by commit, as above.

author_association is MEMBER only for public members of an organization; private members need the label too. In a private repository, drop persist-credentials: false: ocra fetches the pull request's commits with the checkout's credentials.

A review your own developers cannot change

A workflow that someone with push access pushes gets the repository's secrets, and its GITHUB_TOKEN posts as github-actions[bot], the account ocra trusts by default. To keep the review out of their hands, put three things where only your default branch reaches them:

  1. An environment for the secrets. Create an environment, say ocra-review (Settings → Environments), and under Deployment branches and tags allow only your default branch. Move the model key there and delete the repository secret. A pull_request_target job runs on the default branch and gets the environment's secrets; a workflow pushed on another branch does not, and neither does on: pull_request.
  2. An account of ocra's own. Create a GitHub App with the repository permissions Pull requests (read and write) and Contents (read), and install it on the repository. Store its private key in the environment as the secret OCRA_APP_KEY and its client ID as the variable OCRA_APP_CLIENT_ID. In .ocra/config.json on the default branch, set github.botLogin to the app's account, for example { "github": { "botLogin": "my-ocra[bot]" } }: a summary posted as github-actions[bot] then counts for nothing.
  3. Review for the workflow and the configuration. Protect the default branch, and require a code owner's review for .github/workflows/ and .ocra/ (CODEOWNERS).
name: ocra
on:
  pull_request_target:
    types: [opened, synchronize, reopened, labeled]

permissions:
  contents: read

concurrency:
  group: ocra-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    if: >-
      (github.event.action == 'labeled' && github.event.label.name == 'ocra-review') ||
      (github.event.action != 'labeled' &&
        contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association))
    runs-on: ubuntu-latest
    # Its secrets reach only jobs that run on the default branch.
    environment: ocra-review
    steps:
      - id: app
        uses: actions/create-github-app-token@v3
        with:
          client-id: ${{ vars.OCRA_APP_CLIENT_ID }}
          private-key: ${{ secrets.OCRA_APP_KEY }}
      # The default branch. Nothing from the pull request is checked out.
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: jma49/Open-CR-Agent@c2fda45c42880d4c8223b185704f7598c889f831 # v0.4.0
        with:
          github-token: ${{ steps.app.outputs.token }}
          args: --max-cost-usd 2
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

The rules for pull_request_target above apply unchanged. The project has not run this exact setup end to end yet; if a step fails, open an issue.

Configuration

{ "github": { "requestChanges": false, "botLogin": "github-actions[bot]" } }

botLogin is the account whose summary comment counts as ocra's earlier review; change it if you post with another token. Commands such as /ocra override never count from ocra's own summary or from text a model wrote, even when ocra posts with a person's token.

From the command line

export GITHUB_TOKEN=...           # a token that can read the repository
ocra review --pr 42               # review and print, like a local review
ocra review --pr 42 --publish     # also post the review

The token comes from GITHUB_TOKEN or GH_TOKEN; set GITHUB_API_URL for GitHub Enterprise (for example https://github.example.com/api/v3). The repository comes from --repo owner/name, GITHUB_REPOSITORY, or an origin remote on github.com (for GitHub Enterprise pass --repo). ocra fetches the pull request's commits if they are missing locally.

Edit on GitHub

On this page