GitHub pull requests

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

GitHub Action

ocra init --github writes the fork-safe workflow below, or with --same-repo-only this one, for your provider's key (ocra init). By hand, add this workflow to the repository:

name: ocra
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    # A pull request from a fork gets no secrets on pull_request: skip it.
    if: github.event.pull_request.head.repo.full_name == github.repository
    # 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
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
      - uses: jma49/Open-CR-Agent@6d4d4a21b6334c086e0f884c240d550b2e4c1f46 # v0.7.0
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
  • Pin every action by commit, with the version as a comment: a tag can move, a commit cannot. The recipes here and ocra init do, actions/checkout included. git ls-remote --tags https://github.com/jma49/Open-CR-Agent v0.3.0 prints a release's commit; Dependabot updates the pin and comment.
  • Keep fetch-depth: 0: ocra needs the history between base and head; a shallow checkout stops with a message.
  • Keep concurrency: otherwise two quick pushes post the same comments twice, and the slower run can overwrite the summary with an older head. A cancelled run publishes nothing (unless already publishing) and leaves its partial report in the job log.
  • Store the model key as a repository secret. Models come from the base branch's .ocra/config.json or OCRA_MODEL_* variables in env.

ocra retries GitHub's rate limits and short outages a few times, never a request that may already have posted a comment.

Since v0.1.1 the Action installs @open-cr-agent/cli from npm at its own version, dependencies pinned by its package-lock.json, install scripts off and, on the npm registry, signatures checked. It uses them only when each ocra package has provenance from this repository's release workflow at the version's tag; otherwise, or before the version reaches npm, it builds its own source with a warning (v0.1.0 always does). So @main runs main's Action code with the release main names, or builds main if its package dependencies changed since.

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 did not review everything (2 or 3) may write a report, but its verdict says nothing about the unreviewed files. run-id, report and findings are empty when ocra wrote no report. To keep the report and put the verdict in the job summary:

      - id: ocra
        uses: jma49/Open-CR-Agent@6d4d4a21b6334c086e0f884c240d550b2e4c1f46 # v0.7.0
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
      - if: ${{ !cancelled() && steps.ocra.outputs.report != '' }}
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        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, never as ${{ }} inside run.

The verdict is advice (why): do not make the check a required status in place of human review, and with fail-on-concerns read a pass as "nothing confirmed", not "safe".

On pull_request, pull requests from forks get no secrets, so the if: skips them; to review them, see Pull requests from forks.

The runtime

OpenCode, the default runtime, with its native binary 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 it sets "runtime": "direct" (Choosing the runtime), set opencode: false (new in v0.3.0) to install 11 packages. If the configuration then selects opencode, the review stops before any model call with exit code 2, naming the missing package.

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

The install is not cached: each run uses a fresh npm cache, so every package, signature and provenance statement comes from the registry, never from a cache another workflow could have written.

What gets posted

  • Inline comments for findings anchored in the diff, marked verified, unverified or not verified, in one COMMENT review. ocra never approves. With github.requestChanges, a significant_concerns verdict requests changes once, withdrawn (dismissed) when a later verdict no longer blocks or the commit is overridden.
  • One summary comment, updated in place: verdict, summary, findings outside the diff, what was fixed, coverage, cost and the run id. It counts unreviewed files, says whether the spend limit was reached, and lists partly reviewed files (a reviewer out of steps or stopped) with what to do.

Later pushes review only what changed: files changed since the previous review's commit, plus files it did not finish (failed task, reviewer out of steps, spend limit). Findings in unchanged files carry over, open, counted, not commented again. Everything is reviewed again when the previous commit left the branch (force-push, rebase), the push raises the risk tier, the summary was last edited by someone other than ocra (its remembered state is then ignored, though ocra still recognizes its own unedited inline comments), or you pass --full. Changing which reviewers run (reviewers, --reviewers, --ultra) does not; pass --full once. The summary says which kind of review it was and why.

Compared with the previous review:

  • A finding already commented on is not commented again.
  • A finding is fixed only when its code is gone from the file or the file was deleted; it is listed and its thread resolved.
  • A finding not reported this time while its code is unchanged is not reported this time: model runs vary, so it stays open and counts at its severity until the code changes or it is dismissed. The same code gets the same verdict.
  • A finding whose file was not reviewed this time is not re-checked and stays open.

Maintainers stay in charge. A maintainer, someone with write access (admin, maintain or write) other than the author, dismisses a finding by resolving its thread, replying /ocra dismiss, or replying 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. A dismissed finding no longer counts in this pull request unless a later run finds it at a higher severity. The words elsewhere in a reply ("this is not intended, good catch", "is this by design?") do not count, nor does "I disagree". Their other replies go to the judge when the finding returns; it may drop the finding for a specific reason, never a confirmed critical one. The author's replies are not shown and cannot dismiss, and a reply someone else edited does not count. A finding without an inline thread cannot be dismissed; ocra memory silences it instead: at any severity, in every pull request, once .ocra/memory.json is on the base branch.

Committable suggestions

A finding can carry a fix: code that replaces the lines it is about. Its inline comment then ends with a suggestion block that anyone with write access can commit (Commit suggestion, or in a batch). ocra offers one only when the comment sits on exactly the replaced lines, never in the summary, and posts the code exactly or not at all: a fix holding an HTML comment opener, /ocra, a carriage return or a control or bidirectional character, or longer than 20,000 characters, gets no block; the finding is still posted. A suggestion is a model's proposal: read it before committing.

Planned: no reviewer produces fixes yet; that waits for an evaluated prompt change (ADR-0029). Until then comments give suggestions as text.

Code scanning

To also see findings as code scanning alerts, write SARIF with the sarif input and upload it with upload-sarif:

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

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
      - id: ocra
        uses: jma49/Open-CR-Agent@6d4d4a21b6334c086e0f884c240d550b2e4c1f46 # v0.7.0
        with:
          sarif: true
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
      - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
        # 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 and report still names the JSON report: SARIF only changes what ocra prints. args: --format sarif --output ocra.sarif does the same with your own path.
  • Code scanning shows only results with lines, so file-level findings are not shown, nor are earlier findings in files unchanged since (later pushes review only what changed). Add --full to args to show every finding on every push, at the cost of a full review each time.
  • Code scanning is available for public repositories; private ones need GitHub Code Security.
  • File contents: SARIF output.

Importing an analyzer's results

Run Semgrep, CodeQL or another SARIF-writing analyzer first and pass its log with --import-sarif: its results on the change are verified and judged with ocra's own (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@6d4d4a21b6334c086e0f884c240d550b2e4c1f46 # v0.7.0
        with:
          args: --import-sarif semgrep.sarif
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

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

Overriding a blocking verdict

To merge a change ocra blocks, someone with write access other than the author comments:

/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 stays significant_concerns; the summary shows who overrode it and why, and prints the command with the commit id to copy. A new push needs a new override. The full id is required because a crafted commit can match a short prefix. 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 never overridden.

Someone with push access can push a workflow that gets the repository's secrets and posts as github-actions[bot]; with on: pull_request, even the review's workflow 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

.ocra/config.json, .ocra/rules.json, .ocra/memory.json and AGENTS.md come from the base commit, and repository plugins never load. The diff, title and description reach reviewers as untrusted data.

Pull requests from forks

On pull_request, a fork's pull request runs without your secrets and with a read-only token. pull_request_target runs your default branch's workflow file 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

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))
    # One review per pull request at a time: a new push cancels the older run.
    # The key repeats the if:, so a run the if: skips gets a group of its
    # own and cannot cancel the review in progress.
    concurrency:
      group: >-
        ocra-${{ github.event.pull_request.number }}-${{
          ((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)))
          && 'review' || github.run_id }}
      cancel-in-progress: true
    runs-on: ubuntu-latest
    steps:
      # The base branch. Nothing from the pull request is checked out.
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: jma49/Open-CR-Agent@6d4d4a21b6334c086e0f884c240d550b2e4c1f46 # v0.7.0
        with:
          args: --max-cost-usd 2
        env:
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}

GitHub warns against pull_request_target because a job that builds the pull request's code runs it with your secrets. ocra runs nothing from the pull request (Threat model); keep the workflow that way:

  • Never check out, install, build or test the pull request here: no ref: ${{ github.event.pull_request.head.sha }}, no dependency install, no test run. Do 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: an outsider's pull request is reviewed once each time a maintainer adds the label (remove and re-add it for the next review). An outside author's push during a labelled review is skipped and leaves that review running; re-add the label to review the new head. --max-cost-usd caps each review.
  • Keep the condition in the concurrency group, and change both together. A run the if: skips, such as one for an unrelated label, gets a group of its own, so it cannot cancel the review in progress.
  • Use a model key that serves only these reviews, with a spending limit at the provider.
  • Pin the Action by commit.

author_association is MEMBER only for public organization members; 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 pushed by someone with push access 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 one, say ocra-review (Settings → Environments), allowing only your default branch under Deployment branches and tags. Move the model key there and delete the repository secret. A pull_request_target job runs on the default branch and gets them; a workflow pushed on another branch, or on: pull_request, does not.
  2. An account of ocra's own. Create a GitHub App with repository permissions Pull requests (read and write) and Contents (read), and install it. Store its private key as the environment secret OCRA_APP_KEY and its client ID as the variable OCRA_APP_CLIENT_ID. On the default branch, set github.botLogin to the app's account, for example { "github": { "botLogin": "my-ocra[bot]" } }, so a summary posted as github-actions[bot] 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

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))
    # One review per pull request at a time: a new push cancels the older run.
    # The key repeats the if:, so a run the if: skips gets a group of its
    # own and cannot cancel the review in progress.
    concurrency:
      group: >-
        ocra-${{ github.event.pull_request.number }}-${{
          ((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)))
          && 'review' || github.run_id }}
      cancel-in-progress: true
    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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: jma49/Open-CR-Agent@6d4d4a21b6334c086e0f884c240d550b2e4c1f46 # v0.7.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. This exact setup has not been run 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 model text, 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; for GitHub Enterprise set GITHUB_API_URL (for example https://github.example.com/api/v3) and pass --repo. The repository comes from --repo owner/name, GITHUB_REPOSITORY, or an origin remote on github.com. ocra fetches missing pull request commits.

Edit on GitHub

On this page