Skip to content

feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger)#401

Open
chethanuk wants to merge 4 commits into
alibaba:mainfrom
chethanuk:feat/examples-gerrit-ci
Open

feat(examples): add Gerrit CI integration example (Jenkins + Gerrit Trigger)#401
chethanuk wants to merge 4 commits into
alibaba:mainfrom
chethanuk:feat/examples-gerrit-ci

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #316

Implements the approach outlined in my comment on #316: a self-contained examples/gerrit_ci/ following the examples/gitflic_ci CI-glue pattern — a stdlib-only post_review.py that reads ocr review --format json and publishes everything (summary, inline comments with ranges, suggestions, severity) onto the triggering Gerrit change in one REST call, plus a Jenkinsfile wired for the Gerrit Trigger plugin.

Design decisions (each verified against a live Gerrit)

  • Single batched POST /a/changes/{change}/revisions/{rev}/review — one ReviewInput carries the summary message and all inline comments, so a review is atomic (no comment-spam, no partial posts). omit_duplicate_comments makes re-runs idempotent; tag: autogenerated:opencodereview marks bot comments; notify: OWNER avoids spamming all reviewers; drafts: KEEP pinned (depot_tools convention — old servers defaulted to deleting the caller's drafts).
  • Bare line instead of range for multi-line comments — trialed both against Gerrit 3.14: a {start_line, end_line} range with start_character: 0, end_character: 0 excludes the final line in the UI, so the script anchors to end_line and keeps the OCR line span in the message text.
  • Preemptive HTTP Basic auth + XSSI )]}' strip — Gerrit doesn't send a 401 challenge on /a/ paths; the auth header is attached up-front (via add_unredirected_header, so credentials can never follow a redirect to another host) and the XSSI prefix is stripped before JSON parsing. Both match what chromium depot_tools/gerrit_util.py does.
  • Graceful degradation — HTTP 400 (e.g. a comment landing outside the patchset diff) retries once with the inline comments folded into the summary message (truncation warned on stderr, outcome reported honestly); 409 (closed/abandoned change) is tolerated as success-with-note. Verified empirically: Gerrit 3.14 accepts label-free reviews on abandoned changes and out-of-range lines, so both branches are defensive for older/stricter servers.
  • Secrets hygiene — the HTTP password is scrubbed from every error path (including echoed HTML error bodies) before anything reaches CI logs.
  • Stdlib-only, no dependencies — same constraint as the gitflic/gitlab examples; runs on any Jenkins agent with Python 3.

Verification

  • python3 -m unittest examples/gerrit_ci/post_review_test.py -v55/55 pass (matrices for ReviewInput building, endpoint/base-URL derivation incl. GERRIT_CHANGE_URL, XSSI stripping, HTTP outcomes 400/401/404/409, fold-retry failure, truncation, stdin/positional input, flag-over-env precedence, timeout validation)
  • End-to-end against real Gerrit 3.14.2 in Docker: created a project + change via git push refs/for/master, posted a real OCR JSON result with post_review.py — summary + inline comments (single-line, multi-line, suggestion fence, severity header) all render on the change; re-run posts zero duplicates (omit_duplicate_comments); wrong password → clean exit 2, password absent from output; out-of-diff line and abandoned-change paths exercised
  • Go gates untouched by this PR still green: make build, make test, coverage 81.0% (≥80% gate)
  • Repo conventions: example listed in examples/README.md and all five root READMEs (same placement as the GitFlic example, commit 7497d5a)

E2E evidence (Gerrit 3.14.2, Docker)

Change page after post_review.py — summary + inline comments with severity headers and suggestion fence:

Gerrit change page with posted OCR review

Inline anchoring details (bare-line rendering + range trial)

Bare line anchoring on lines 3 and 10:

Inline comments anchored at lines 3 and 10

Range trial (lines 5–8) that motivated the bare-line decision — the character-0 range excludes the final line in the UI:

Range comment trial on lines 5-8
Full unit test output (55 tests)
test_context_path_base (examples.gerrit_ci.post_review_test.BuildEndpointTest.test_context_path_base) ... ok
test_revision_default_current (examples.gerrit_ci.post_review_test.BuildEndpointTest.test_revision_default_current) ... ok
test_trailing_slash_base (examples.gerrit_ci.post_review_test.BuildEndpointTest.test_trailing_slash_base) ... ok
test_url_from_change_url_legacy (examples.gerrit_ci.post_review_test.BuildEndpointTest.test_url_from_change_url_legacy) ... ok
test_url_from_change_url_modern (examples.gerrit_ci.post_review_test.BuildEndpointTest.test_url_from_change_url_modern) ... ok
test_url_from_change_url_path_prefix (examples.gerrit_ci.post_review_test.BuildEndpointTest.test_url_from_change_url_path_prefix) ... ok
test_batch_metadata (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_batch_metadata) ... ok
test_empty_comments_summary_only (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_empty_comments_summary_only) ... ok
test_file_level_comment (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_file_level_comment) ... ok
test_missing_line_keys (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_missing_line_keys) ... ok
test_missing_path (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_missing_path) ... ok
test_multi_line_range (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_multi_line_range) ... ok
test_multiple_files_grouped (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_multiple_files_grouped) ... ok
test_negative_start_line (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_negative_start_line) ... ok
test_path_with_spaces (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_path_with_spaces) ... ok
test_result_message_only (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_result_message_only) ... ok
test_severity_category_line (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_category_line) ... ok
test_severity_critical_unresolved (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_critical_unresolved) ... ok
test_severity_high_unresolved (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_high_unresolved) ... ok
test_severity_line_omitted_when_unset (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_line_omitted_when_unset) ... ok
test_severity_low_resolved (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_low_resolved) ... ok
test_severity_medium_resolved (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_medium_resolved) ... ok
test_severity_missing_resolved (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_severity_missing_resolved) ... ok
test_single_line_comment (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_single_line_comment) ... ok
test_start_gt_end_degenerate (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_start_gt_end_degenerate) ... ok
test_suggestion_block (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_suggestion_block) ... ok
test_suggestion_without_existing (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_suggestion_without_existing) ... ok
test_unicode_comment_roundtrip (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_unicode_comment_roundtrip) ... ok
test_very_long_message_truncated (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_very_long_message_truncated) ... ok
test_warnings_in_summary (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_warnings_in_summary) ... ok
test_warnings_only_no_comments (examples.gerrit_ci.post_review_test.BuildReviewInputTest.test_warnings_only_no_comments) ... ok
test_preemptive_basic_auth_and_utf8_body (examples.gerrit_ci.post_review_test.MakePosterTest.test_preemptive_basic_auth_and_utf8_body) ... ok
test_xssi_response_parses (examples.gerrit_ci.post_review_test.MakePosterTest.test_xssi_response_parses) ... ok
test_timeout_float_still_parses (examples.gerrit_ci.post_review_test.ParseArgsTest.test_timeout_float_still_parses) ... ok
test_timeout_must_be_positive (examples.gerrit_ci.post_review_test.ParseArgsTest.test_timeout_must_be_positive) ... ok
test_auth_failure_401 (examples.gerrit_ci.post_review_test.PostTest.test_auth_failure_401) ... ok
test_bad_request_400_falls_back (examples.gerrit_ci.post_review_test.PostTest.test_bad_request_400_falls_back) ... ok
test_change_closed_409 (examples.gerrit_ci.post_review_test.PostTest.test_change_closed_409) ... ok
test_change_url_derivation (examples.gerrit_ci.post_review_test.PostTest.test_change_url_derivation) ... ok
test_dry_run_no_credentials (examples.gerrit_ci.post_review_test.PostTest.test_dry_run_no_credentials) ... ok
test_env_resolution (examples.gerrit_ci.post_review_test.PostTest.test_env_resolution) ... ok
test_flags_override_env (examples.gerrit_ci.post_review_test.PostTest.test_flags_override_env) ... ok
test_fold_retry_also_fails (examples.gerrit_ci.post_review_test.PostTest.test_fold_retry_also_fails) ... ok
test_fold_truncation_and_message (examples.gerrit_ci.post_review_test.PostTest.test_fold_truncation_and_message) ... ok
test_missing_required_env (examples.gerrit_ci.post_review_test.PostTest.test_missing_required_env) ... ok
test_non_dict_json_input (examples.gerrit_ci.post_review_test.PostTest.test_non_dict_json_input) ... ok
test_non_gerrit_response_body (examples.gerrit_ci.post_review_test.PostTest.test_non_gerrit_response_body) ... ok
test_not_found_404 (examples.gerrit_ci.post_review_test.PostTest.test_not_found_404) ... ok
test_password_never_in_error_body (examples.gerrit_ci.post_review_test.PostTest.test_password_never_in_error_body) ... ok
test_positional_input (examples.gerrit_ci.post_review_test.PostTest.test_positional_input) ... ok
test_positional_input_wins_over_flag (examples.gerrit_ci.post_review_test.PostTest.test_positional_input_wins_over_flag) ... ok
test_single_post_batching (examples.gerrit_ci.post_review_test.PostTest.test_single_post_batching) ... ok
test_stdin_input (examples.gerrit_ci.post_review_test.PostTest.test_stdin_input) ... ok
test_timeout_urlerror (examples.gerrit_ci.post_review_test.PostTest.test_timeout_urlerror) ... ok
test_strip_xssi (examples.gerrit_ci.post_review_test.StripXssiTest.test_strip_xssi) ... ok

----------------------------------------------------------------------
Ran 55 tests in 0.032s

OK

Happy to wire python3 -m unittest discover examples into CI as a follow-up if you'd like the example tests gated.

Adds examples/gerrit_ci following the gitflic_ci CI-glue pattern (alibaba#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).
- 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.
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ Successfully posted inline: 3 comment(s)

Comment thread examples/gerrit_ci/Jenkinsfile Outdated
Comment on lines +49 to +50
ocr config set llm.url "$OCR_LLM_URL"
ocr config set llm.auth_token "$OCR_LLM_AUTH_TOKEN"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: LLM auth token persisted on disk. ocr config set llm.auth_token writes the secret to ~/.opencodereview/config.json (confirmed in config_cmd.go). On shared Jenkins agents this file survives the build and can be read by subsequent jobs from other projects/users.

Consider adding a cleanup step in a post { always { ... } } block to remove the config file after the pipeline completes, or use a per-build OCR_CONFIG_PATH pointing into the workspace so it gets cleaned up automatically:

environment {
    OCR_CONFIG_PATH = "${WORKSPACE}/.ocr-config.json"
}

This way the config (including the token) lives inside the workspace and is removed by Jenkins' workspace cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in db49e19, though not with OCR_CONFIG_PATH: ocr config set and ocr review deliberately ignore that variable (only read-only commands like ocr llm test honor it — see config_cmd.go, the comment there notes a leaked OCR_CONFIG_PATH must not redirect writes), so pointing it at the workspace would not have kept the token off disk.

The root fix is to skip the config file entirely: ocr resolves the OCR_LLM_URL / OCR_LLM_TOKEN / OCR_LLM_MODEL env triple directly (resolver.go tryOCREnv, tried after the config file), so the Jenkinsfile now sets those in the environment block with the token as a masked credentials() binding — nothing is ever written to ~/.opencodereview/config.json. The one case that still needs the config file, extra_body (no env equivalent), is documented with the post { always { rm -f ... } } cleanup you suggested.

Comment thread examples/gerrit_ci/Jenkinsfile Outdated

stage('Install OCR') {
steps {
sh 'npm install -g @alibaba-group/open-code-review'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproducibility / supply-chain risk: Installing globally without a pinned version makes builds non-reproducible and vulnerable to supply-chain attacks if a compromised or breaking version is published. For an example that users will copy into production pipelines, pin to a known-good version:

sh 'npm install -g @alibaba-group/open-code-review@<version>'

Also consider using a local install (npm install in the workspace) instead of -g to avoid conflicts between concurrent builds on shared agents.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in db49e19 — pinned to @alibaba-group/open-code-review@1.7.12 (current latest) with a comment to bump deliberately. Kept it a global install to match the other CI examples (gitflic_ci, gitlab_ci); a workspace-local install would diverge from the established pattern for little gain on a single-tool image.

Comment on lines +204 to +206
credentials = base64.b64encode(
("%s:%s" % (user, password)).encode("utf-8")
).decode("ascii")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scrub() function only removes the raw password from error output, but the base64-encoded credentials string (which encodes user:password) is not scrubbed. If an HTTPError response body, proxy log, or exception message includes the Authorization header value (e.g., some servers/proxies echo request headers in error responses), the base64-encoded credentials would be logged in plain text and could be trivially decoded.

Consider also scrubbing the base64-encoded credentials string. You could pass it as an additional value to scrub, or restructure make_poster so the caller can scrub both forms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in db49e19. scrub() now also replaces the exact base64(user:password) string that make_poster puts in the Authorization header, so a proxy or server that echoes the request header in an error body can no longer leak decodable credentials. Added test_b64_credentials_never_in_error_body (401 body echoing Basic <b64>) alongside the existing raw-password test.

- 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.
chethanuk added a commit to chethanuk/open-code-review that referenced this pull request Jul 17, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support publishing review results to Gerrit Change

1 participant