Skip to content

Commit 870fc6a

Browse files
authored
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).
1 parent efac9ec commit 870fc6a

10 files changed

Lines changed: 1287 additions & 0 deletions

File tree

README.ja-JP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと
497497
- [`github_actions/`](./examples/github_actions/) — GitHub Actions統合の例
498498
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI統合の例
499499
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI統合の例
500+
- [`gerrit_ci/`](./examples/gerrit_ci/) — Gerrit (Jenkins / Gerrit Trigger) 統合の例
500501

501502
#### GitHub Action
502503

README.ko-KR.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s
497497
- [`github_actions/`](./examples/github_actions/): GitHub Actions 통합 예시
498498
- [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시
499499
- [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시
500+
- [`gerrit_ci/`](./examples/gerrit_ci/): Gerrit (Jenkins / Gerrit Trigger) 통합 예시
500501

501502
#### GitHub Action
502503

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ See the [`examples/`](./examples/) directory for integration examples:
499499
- [`github_actions/`](./examples/github_actions/) — GitHub Actions integration example
500500
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI integration example
501501
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI integration example
502+
- [`gerrit_ci/`](./examples/gerrit_ci/) — Gerrit (Jenkins / Gerrit Trigger) integration example
502503

503504
#### GitHub Action
504505

README.ru-RU.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ ocr review \
499499
- [`github_actions/`](./examples/github_actions/) — пример интеграции с GitHub Actions
500500
- [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI
501501
- [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI
502+
- [`gerrit_ci/`](./examples/gerrit_ci/) — пример интеграции с Gerrit (Jenkins / Gerrit Trigger)
502503

503504
#### GitHub Action
504505

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ ocr review \
497497
- [`github_actions/`](./examples/github_actions/) — GitHub Actions 集成示例
498498
- [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例
499499
- [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例
500+
- [`gerrit_ci/`](./examples/gerrit_ci/) — Gerrit (Jenkins / Gerrit Trigger) 集成示例
500501

501502
#### GitHub Action
502503

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario
77
- **[github_actions/](./github_actions/)** - GitHub Actions integration example
88
- **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example
99
- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example
10+
- **[gerrit_ci/](./gerrit_ci/)** - Gerrit (Jenkins / Gerrit Trigger) integration example
1011

1112
Each subdirectory contains its own README with detailed setup instructions.

examples/gerrit_ci/Jenkinsfile

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// OpenCodeReview - Gerrit patchset auto-review (Jenkins + Gerrit Trigger)
2+
//
3+
// Reviews every new patchset with OpenCodeReview and posts the findings back
4+
// onto the change via post_review.py (see README.md in this directory).
5+
//
6+
// Trigger: configure the Gerrit Trigger plugin ON THE JOB (Configure ->
7+
// Gerrit Trigger -> trigger on "Patchset Created"). A declarative
8+
// `triggers { gerrit(...) }` block exists, but its map syntax varies across
9+
// plugin versions, so real jobs configure the trigger in the UI. The plugin
10+
// injects GERRIT_BRANCH, GERRIT_REFSPEC, GERRIT_CHANGE_NUMBER,
11+
// GERRIT_CHANGE_URL and GERRIT_PATCHSET_REVISION, used below.
12+
//
13+
// Jenkins credentials: ocr-llm-auth-token (Secret text, LLM API token) and
14+
// gerrit-http (Username/password: bot account + its Gerrit HTTP password --
15+
// Settings > HTTP Credentials, NOT the account password).
16+
// The agent needs git, node/npm 20+ and python3 on PATH.
17+
18+
pipeline {
19+
agent any
20+
21+
environment {
22+
// ocr resolves OCR_LLM_URL / OCR_LLM_TOKEN / OCR_LLM_MODEL directly
23+
// from the environment (no `ocr config set`), so the token stays
24+
// env-only and is never written to disk on a shared agent.
25+
OCR_LLM_URL = 'https://api.openai.com/v1/chat/completions'
26+
OCR_LLM_MODEL = 'gpt-4o'
27+
OCR_LLM_TOKEN = credentials('ocr-llm-auth-token')
28+
// The env resolver defaults to the Anthropic protocol; this is an
29+
// OpenAI chat-completions endpoint.
30+
OCR_USE_ANTHROPIC = 'false'
31+
// GERRIT_URL: set here or in Jenkins global env; if unset,
32+
// post_review.py derives it from the injected GERRIT_CHANGE_URL.
33+
}
34+
35+
stages {
36+
stage('Checkout') {
37+
steps {
38+
// Fresh workspaces lack the target ref, so fetch the branch
39+
// with an explicit refspec that materializes the tracking ref
40+
// (ocr diffs against origin/$GERRIT_BRANCH; a bare fetch only
41+
// updates FETCH_HEAD when the clone refspec is narrow), then
42+
// fetch the patchset ref and check out its revision.
43+
sh '''
44+
git fetch origin "+refs/heads/$GERRIT_BRANCH:refs/remotes/origin/$GERRIT_BRANCH"
45+
git fetch origin "$GERRIT_REFSPEC"
46+
git checkout "$GERRIT_PATCHSET_REVISION"
47+
'''
48+
}
49+
}
50+
51+
stage('Install OCR') {
52+
steps {
53+
// Pin the version you have validated; bump deliberately.
54+
sh 'npm install -g @alibaba-group/open-code-review@1.7.12'
55+
// extra_body has no env-var equivalent. Providers that accept
56+
// a top-level "thinking" field (e.g. DashScope/GLM; OpenAI
57+
// rejects unknown fields) must fall back to the config file,
58+
// which then needs the full triple (the config-file strategy
59+
// resolves url/token/model together, ignoring the env vars):
60+
// ocr config set llm.url/auth_token/model/use_anthropic ...
61+
// ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'
62+
// That writes the token to ~/.opencodereview/config.json, so on
63+
// a shared agent clean it up with:
64+
// post { always { sh 'rm -f ~/.opencodereview/config.json' } }
65+
}
66+
}
67+
68+
stage('Review') {
69+
steps {
70+
// `|| true` is REQUIRED: `ocr review` exits non-zero only
71+
// when EVERY sub-agent fails; partial failures still exit 0.
72+
// The guard in the next stage handles the empty-output case.
73+
sh '''
74+
ocr review \
75+
--from "origin/$GERRIT_BRANCH" \
76+
--to "$GERRIT_PATCHSET_REVISION" \
77+
--format json \
78+
--audience agent \
79+
> result.json || true
80+
'''
81+
}
82+
}
83+
84+
stage('Post to Gerrit') {
85+
steps {
86+
withCredentials([usernamePassword(credentialsId: 'gerrit-http', usernameVariable: 'GERRIT_HTTP_USER', passwordVariable: 'GERRIT_HTTP_PASSWORD')]) {
87+
// post_review.py sits in the reviewed repo only in this
88+
// demo; real jobs vendor it or fetch it:
89+
// curl -fsSLO https://raw.githubusercontent.com/alibaba/open-code-review/main/examples/gerrit_ci/post_review.py
90+
// --revision pins the reviewed SHA explicitly: the
91+
// `current` default would retarget comments if a new
92+
// patchset landed mid-pipeline. GERRIT_CHANGE_NUMBER and
93+
// GERRIT_URL/GERRIT_CHANGE_URL come from the environment.
94+
sh '''
95+
if [ ! -s result.json ]; then
96+
echo "OCR review produced no output, skipping post."
97+
exit 0
98+
fi
99+
python3 examples/gerrit_ci/post_review.py \
100+
--input result.json \
101+
--revision "$GERRIT_PATCHSET_REVISION"
102+
'''
103+
}
104+
}
105+
}
106+
}
107+
}

examples/gerrit_ci/README.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# OpenCodeReview - Gerrit CI Demo
2+
3+
This demo shows how to integrate OpenCodeReview into a [Gerrit](https://www.gerritcodereview.com) code-review flow to automatically review changes and post the findings as inline comments on the patchset, plus a summary message.
4+
5+
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`](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.
6+
7+
## How It Works
8+
9+
```
10+
Gerrit Trigger (patchset-created) → Jenkins job → ocr review --format json → post_review.py → POST /a/changes/{change}/revisions/{revision}/review
11+
```
12+
13+
1. The Gerrit Trigger plugin fires a Jenkins job on `patchset-created`
14+
2. The job fetches the change ref and runs `ocr review --format json --audience agent`
15+
3. `python3 post_review.py` reads the JSON and POSTs a single `ReviewInput` containing:
16+
- **Inline comments** on the changed lines, grouped per file
17+
- **A summary message** with the totals (plus any comments that could not be placed inline)
18+
19+
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).
20+
21+
## Setup
22+
23+
### 1. Create a bot account and HTTP password
24+
25+
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**.
26+
27+
> **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.
28+
29+
### 2. Grant minimal permissions
30+
31+
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).
32+
33+
### 3. Configure the pipeline
34+
35+
Copy `post_review.py` into your repository (or fetch it in the job) and wire it into your Jenkins job — see the [`Jenkinsfile`](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.
36+
37+
## Configuration Reference
38+
39+
Every value can be passed via flag or environment variable; **flags override the environment**. Run `python3 post_review.py -h` for the same list.
40+
41+
| Flag | Env fallback | Default | Description |
42+
|------|--------------|---------|-------------|
43+
| `--gerrit-url` | `GERRIT_URL`, else derived from `GERRIT_CHANGE_URL` || Gerrit base URL (context path kept, trailing slash tolerated) |
44+
| `--change` | `GERRIT_CHANGE_NUMBER` || Change number |
45+
| `--revision` | `GERRIT_PATCHSET_REVISION` | `current` | Revision/patchset to comment on |
46+
| `--user` | `GERRIT_HTTP_USER` || HTTP credentials username |
47+
| `--password` | `GERRIT_HTTP_PASSWORD` || Gerrit HTTP password, **not** the account password |
48+
| `--input` || `-` (stdin) | Review result JSON file (`-` = stdin); also accepted as a positional argument, which wins if both are given |
49+
| `--dry-run` || off | Print the ReviewInput instead of posting it |
50+
| `--timeout` || `30` | HTTP timeout in seconds |
51+
52+
`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.
53+
54+
## Usage
55+
56+
### Jenkins (Gerrit Trigger)
57+
58+
See the [`Jenkinsfile`](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:
59+
60+
```bash
61+
ocr review --format json --audience agent | python3 post_review.py
62+
```
63+
64+
### Other triggers
65+
66+
The script is trigger-agnostic: anything that can set the environment variables above (or pass the flags) can drive it.
67+
68+
- **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.
69+
- **`patchset-created` hook** — post directly from a server-side hook:
70+
71+
```bash
72+
ocr review --format json | python3 post_review.py \
73+
--gerrit-url https://gerrit.example.com --change "$CHANGE" --revision "$REVISION"
74+
```
75+
76+
## Dry Run
77+
78+
Test the posting step locally without touching the change (no credentials required):
79+
80+
```bash
81+
ocr review --from origin/main --to HEAD --format json > /tmp/r.json
82+
python3 post_review.py /tmp/r.json --dry-run # or: --input /tmp/r.json
83+
```
84+
85+
`--dry-run` prints the exact ReviewInput that would be POSTed:
86+
87+
```json
88+
{
89+
"message": "OpenCodeReview found 2 issue(s) in 1 file(s) reviewed; 2 posted as inline comment(s).",
90+
"tag": "autogenerated:opencodereview",
91+
"notify": "OWNER",
92+
"omit_duplicate_comments": true,
93+
"comments": {
94+
"internal/scan/scan.go": [
95+
{ "message": "**Severity:** high · ...", "unresolved": true, "line": 42 },
96+
...
97+
```
98+
99+
## Notes & Limitations
100+
101+
- **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.
102+
- **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.
103+
- **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.
104+
- **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).
105+
- **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.
106+
- **Exit codes** — `0` 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.
107+
108+
## Tests
109+
110+
`post_review.py` ships with [`post_review_test.py`](post_review_test.py) — standard-library `unittest`, no network or git required:
111+
112+
```bash
113+
cd examples/gerrit_ci
114+
python3 post_review_test.py
115+
```

0 commit comments

Comments
 (0)