open-code-review/examples/gerrit_ci
chethanuk 870fc6a4a9
feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger) (#401)
* feat(examples): add Gerrit CI integration for publishing review results

Adds examples/gerrit_ci following the gitflic_ci CI-glue pattern (#316):
a stdlib-only post_review.py that reads 'ocr review --format json' and
publishes summary, inline comments, suggestion blocks, and file-level
findings in ONE batched POST /a/changes/{change}/revisions/{rev}/review,
tagged autogenerated:opencodereview with notify=OWNER and
omit_duplicate_comments.

Decisions (validated against a live Gerrit 3.14 in Docker):
- Plain comments, bare line = end_line, no CommentRange: the range form
  {start,0,end,0} renders lines start..end-1 in the UI (end_character 0
  excludes the final line), so correct ranges would need file contents
  in CI. Verified via UI screenshots during E2E.
- Preemptive HTTP Basic auth (urllib's handler does not preempt),
  XSSI )]}' stripping, HTML-200 detected as config error, 400 batch
  fold-retry, 409 change-closed tolerated (defensive on modern Gerrit:
  label-free reviews post fine on closed changes), password scrubbed
  from all error output.
- Jenkins Gerrit Trigger as the reference integration; the script is
  trigger-agnostic via flags/env (Zuul/hook recipes in the README).
  Jenkinsfile always passes the injected patchset SHA (revision race).

45 table-driven stdlib-unittest tests, red-to-green TDD; E2E against
gerritcodereview/gerrit covering live post, unicode round-trip, dedup
re-run, and failure paths (401 exit 2 with scrubbed password).

* fix(examples/gerrit_ci): harden post_review.py per review findings

- Send Authorization via add_unredirected_header so Basic credentials
  cannot follow a redirect to another host (urllib forwards ordinary
  headers cross-host).
- Reject non-object JSON input cleanly instead of an AttributeError
  traceback; validate --timeout > 0 at argparse time.
- Warn on stderr when the 400-fallback folded summary is truncated, and
  report the fold accurately instead of claiming N inline comments.
- Pin drafts=KEEP in ReviewInput (depot_tools convention; old servers
  defaulted to deleting the caller's drafts).
- Jenkinsfile: fetch with an explicit dest refspec so
  origin/$GERRIT_BRANCH materializes under narrow-refspec clones;
  comment out extra_body thinking (OpenAI rejects unknown fields).
- Gitflic parity: optional positional input arg; single-sourced
  'current' revision default; scrub() skips sub-4-char passwords.
- README: document exit 1 and the defensive 409 branch; add gerrit_ci
  row to all five root READMEs (parity with the GitFlic example).
- Tests: 44 -> 55, covering stdin input, flag-over-env precedence,
  fold-retry failure, fold truncation, GERRIT_CHANGE_URL wiring,
  non-dict JSON, timeout validation, positional input.

* fix(examples/gerrit_ci): address OCR bot review on #401

- Jenkinsfile: resolve the LLM endpoint from the OCR_LLM_URL/TOKEN/MODEL
  env triple instead of `ocr config set`, so the auth token stays
  env-only and is never written to ~/.opencodereview/config.json on a
  shared agent (OCR_CONFIG_PATH is deliberately ignored by write paths,
  so it can't redirect the leak). Pin the npm install to a validated
  version. Document the config-file fallback (and its cleanup) for
  extra_body, which has no env equivalent.
- post_review.py: scrub the base64(user:password) Authorization value
  from error output too, not just the raw password — a proxy echoing the
  request header would otherwise leak decodable credentials. +1 test.

* feat(examples/gerrit_ci): scoped retry + robustness polish

Post-review hardening from an OSS-precedent study (depot_tools, kudu,
Gerrit REST docs):

- Bounded retry (3 attempts, exp backoff) in make_poster, scoped to the
  provably-safe failures only: HTTP 5xx and pre-response connection
  errors (refused/reset/DNS). Read-timeouts are deliberately NOT retried
  — a timeout is ambiguous (the server may have applied the review) and
  omit_duplicate_comments dedupes only inline comments, not the summary
  message, so a blind retry could post a duplicate change message. 4xx
  (400/401/404/409) propagate unchanged so main() classifies them as
  before.
- Document why plain comments are used, not robot_comments: the latter
  is deprecated since Gerrit 3.6, disabled-by-default in 3.12, and
  slated for removal; the tag already marks bot origin.
- Fold fallback: strip the '; N posted as inline comment(s).' clause
  from the reused summary so the folded message doesn't claim inline
  comments were posted and then explain they couldn't be placed.
- README: document the retry scoping and note fix_suggestions / label
  voting as intentional future options.

Tests: 56 -> 61 (5 retry cases: 5xx-then-ok, conn-err-then-ok,
5xx-exhaust, read-timeout-not-retried, 4xx/409-not-retried).
2026-07-21 13:25:30 +08:00
..
Jenkinsfile feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger) (#401) 2026-07-21 13:25:30 +08:00
post_review.py feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger) (#401) 2026-07-21 13:25:30 +08:00
post_review_test.py feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger) (#401) 2026-07-21 13:25:30 +08:00
README.md feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger) (#401) 2026-07-21 13:25:30 +08:00

OpenCodeReview - Gerrit CI Demo

This demo shows how to integrate OpenCodeReview into a Gerrit code-review flow to automatically review changes and post the findings as inline comments on the patchset, plus a summary message.

Like the GitHub Actions, GitLab CI, and GitFlic examples, the posting glue lives in the CI layer rather than in the ocr binary. Here it is a small, dependency-free Python script — post_review.py — that reads ocr review --format json and posts it as one batched ReviewInput to Gerrit's set-review endpoint, so a review lands atomically: inline comments grouped per file plus a summary message, in a single request.

How It Works

Gerrit Trigger (patchset-created) → Jenkins job → ocr review --format json → post_review.py → POST /a/changes/{change}/revisions/{revision}/review
  1. The Gerrit Trigger plugin fires a Jenkins job on patchset-created
  2. The job fetches the change ref and runs ocr review --format json --audience agent
  3. python3 post_review.py reads the JSON and POSTs a single ReviewInput containing:
    • Inline comments on the changed lines, grouped per file
    • A summary message with the totals (plus any comments that could not be placed inline)

The script handles the Gerrit-specific wrinkles for you: preemptive HTTP basic auth on /a/ endpoints, the )]}' anti-XSSI prefix on responses, a 200 that is actually an HTML login page (treated as a configuration error), and an HTTP 400 on the batch (retried once with all inline comments folded into the summary message so findings still reach the change).

Setup

1. Create a bot account and HTTP password

Create a dedicated Gerrit account for the bot (its name appears on the review comments). Log in as that account and generate a token under Settings → HTTP Credentials → Generate Password.

Important: this HTTP password is not the account/login password — using the login password is the most common cause of HTTP 401 from /a/ endpoints. post_review.py says so explicitly when Gerrit rejects the credentials.

2. Grant minimal permissions

The bot only needs to read changes and comment on them. In the project's (or All-Projects') access settings, grant the bot's group Read on refs/* — commenting on open changes requires nothing more. Do not grant Label: Code-Review voting rights unless you enable label voting (see Notes).

3. Configure the pipeline

Copy post_review.py into your repository (or fetch it in the job) and wire it into your Jenkins job — see the Jenkinsfile in this directory. Store GERRIT_HTTP_USER / GERRIT_HTTP_PASSWORD as Jenkins credentials, plus the LLM API token for the review step itself. ocr resolves OCR_LLM_URL / OCR_LLM_TOKEN / OCR_LLM_MODEL directly from the environment — no ocr config set needed, so the token is never written to disk on a shared agent.

Configuration Reference

Every value can be passed via flag or environment variable; flags override the environment. Run python3 post_review.py -h for the same list.

Flag Env fallback Default Description
--gerrit-url GERRIT_URL, else derived from GERRIT_CHANGE_URL Gerrit base URL (context path kept, trailing slash tolerated)
--change GERRIT_CHANGE_NUMBER Change number
--revision GERRIT_PATCHSET_REVISION current Revision/patchset to comment on
--user GERRIT_HTTP_USER HTTP credentials username
--password GERRIT_HTTP_PASSWORD Gerrit HTTP password, not the account password
--input - (stdin) Review result JSON file (- = stdin); also accepted as a positional argument, which wins if both are given
--dry-run off Print the ReviewInput instead of posting it
--timeout 30 HTTP timeout in seconds

GERRIT_CHANGE_URL derivation: modern change URLs ({base}/c/{project}/+/{number}) split at /c/, which keeps any context path; legacy URLs ({base}/{number}) drop the trailing number.

Usage

Jenkins (Gerrit Trigger)

See the Jenkinsfile in this directory. The Gerrit Trigger plugin injects GERRIT_CHANGE_NUMBER, GERRIT_PATCHSET_REVISION, and GERRIT_CHANGE_URL into the build environment, so the script needs no positional wiring:

ocr review --format json --audience agent | python3 post_review.py

Other triggers

The script is trigger-agnostic: anything that can set the environment variables above (or pass the flags) can drive it.

  • Zuul — the same env contract can be set in a Zuul job from zuul.change / zuul.patchset variables; the script does not care who exported them.

  • patchset-created hook — post directly from a server-side hook:

    ocr review --format json | python3 post_review.py \
      --gerrit-url https://gerrit.example.com --change "$CHANGE" --revision "$REVISION"
    

Dry Run

Test the posting step locally without touching the change (no credentials required):

ocr review --from origin/main --to HEAD --format json > /tmp/r.json
python3 post_review.py /tmp/r.json --dry-run     # or: --input /tmp/r.json

--dry-run prints the exact ReviewInput that would be POSTed:

{
  "message": "OpenCodeReview found 2 issue(s) in 1 file(s) reviewed; 2 posted as inline comment(s).",
  "tag": "autogenerated:opencodereview",
  "notify": "OWNER",
  "omit_duplicate_comments": true,
  "comments": {
    "internal/scan/scan.go": [
      { "message": "**Severity:** high · ...", "unresolved": true, "line": 42 },
      ...

Notes & Limitations

  • Re-reviews and duplicates — the ReviewInput sets omit_duplicate_comments, but Gerrit only drops byte-identical comments at the same location. A fresh LLM run usually rewords its findings, so re-triggered reviews will repeat comments. All bot comments carry the tag autogenerated:opencodereview, so UIs and scripts can filter or collapse them.
  • Retries — the POST is retried (up to MAX_ATTEMPTS, with exponential backoff) only on failures that provably did not apply the review: HTTP 5xx and pre-response connection errors (refused/reset/DNS). Read timeouts are not retried — they are ambiguous (the server may have applied the review) and omit_duplicate_comments does not dedupe the summary message, so a retry could post a duplicate change message. 4xx responses (400/401/404/409) are not retried; they are classified and handled directly.
  • Native fix suggestions and label voting — intentional future options. fix_suggestions (Gerrit's native apply-a-fix) is experiment-gated in Gerrit 3.10+ and needs character-level ranges this example does not carry, so suggested code is emitted under a **Suggestion:** heading in a plain fenced code block, which renders portably on any Gerrit version. Label voting (Code-Review ±1) is likewise left out of v1 — see Label voting below.
  • Label voting — deliberately not in v1: the script comments, it does not vote. Teams that want gating can add one line to the ReviewInput in build_review_input: "labels": {"Code-Review": -1} (and grant the bot the label permission).
  • Revision race — always pass the trigger-injected revision SHA (GERRIT_PATCHSET_REVISION). The current default is for manual runs: if a new patchset lands mid-pipeline, current would retarget the comments onto code the review never saw.
  • Exit codes0 on success (HTTP 409 change-closed is tolerated and also exits 0); 1 when the input JSON is unreadable or unparsable; 2 on configuration or HTTP errors, so the CI step fails visibly. The 409 handling is defensive: modern Gerrit (verified on 3.14) accepts label-free reviews even on abandoned changes, so it matters mainly for older/stricter servers.

Tests

post_review.py ships with post_review_test.py — standard-library unittest, no network or git required:

cd examples/gerrit_ci
python3 post_review_test.py