Files
thxCode 0a74720560 feat(action): first-class review controls (effort, max_tokens_budget, llm_reasoning_effort) and live progress (#1154)
* feat(action): expose effort and max_tokens_budget as inputs

- Add validated, case-insensitive effort and base-10 max_tokens_budget
  workflow inputs, forwarded to ocr review as --effort and
  --max-tokens-budget; empty values omit the flags so CLI defaults apply
- Reject an explicit effort input on ocr older than v1.10.0, where the
  flag first shipped, instead of dying on an unknown flag
- Join both axes to the checkpoint config fingerprint

Closes #1147

Signed-off-by: thxCode <thxcode0824@gmail.com>

* feat(action): opt-in live review progress via stream_progress

- add stream_progress input (true|false, case-insensitive, default
  'false'), validated and normalized in the Validate inputs step
- the default keeps the original behavior: --audience agent in the
  review args, stderr captured to /tmp/ocr-stderr.log with no live tee
- stream_progress=true drops --audience agent so human-audience routing
  sends [ocr] progress lines to stderr while the result JSON stays on
  stdout; a FIFO feeds a background tee so progress streams live into
  the workflow log and is still captured to /tmp/ocr-stderr.log for
  artifacts and the posting step
- the tee runs as a real background job and is awaited before the log
  file is read, so the capture is fully flushed; OCR_EXIT_CODE is
  unaffected in either mode

Task 2 of action-review-controls.

Signed-off-by: thxCode <thxcode0824@gmail.com>

* feat(action): first-class llm_reasoning_effort input

- Merge llm_reasoning_effort into the effective llm.extra_body via node
  in the Configure step, riding the existing extra_body merge so any
  published CLI supports it; an explicit reasoning_effort key in
  llm_extra_body wins
- Reject llm_reasoning_effort on the anthropic protocol, and reject a
  malformed or non-object llm_extra_body with an actionable ::error::
- Fingerprint the normalized effort, max_tokens_budget, and
  llm_reasoning_effort values instead of the raw inputs, so equivalent
  spellings keep the checkpoint
- Keep the last 20000 characters of stderr in the unparseable-result
  summary so streamed progress cannot push the error report past
  GitHub's comment limit
- Document the new action inputs on the CI/CD docs page (en/zh/ja/ko/ru)

Closes #1149

Signed-off-by: thxCode <thxcode0824@gmail.com>

---------

Signed-off-by: thxCode <thxcode0824@gmail.com>
2026-09-07 14:10:56 +08:00

959 lines
46 KiB
YAML

name: OpenCodeReview PR Review
description: >-
AI-powered GitHub PR review with inline comments, sticky summary, and
incremental non-destructive posting.
author: alibaba
branding:
icon: eye
color: green
inputs:
llm_url:
description: LLM API endpoint URL (mapped to env OCR_LLM_URL).
required: true
llm_auth_token:
description: LLM auth token (mapped to env OCR_LLM_TOKEN).
required: true
llm_model:
description: Model name (mapped to env OCR_LLM_MODEL).
required: true
llm_use_anthropic:
description: >-
Selects the LLM protocol (mapped to env OCR_USE_ANTHROPIC). An explicitly supplied empty
string, true, 1, or yes selects Anthropic
case-insensitively; every other value selects the OpenAI-compatible
protocol, preserving the CLI environment contract.
required: true
llm_auth_header:
description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER).
required: false
llm_extra_headers:
description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS).
required: false
llm_extra_body:
description: >-
extra_body JSON for LLM requests. No env var exists for this, so it is
written via `ocr config set llm.extra_body`. The default disables
thinking mode for compatibility with various LLM providers; override it
with provider-specific JSON when a model needs different behavior. An
explicit reasoning_effort key here wins over the llm_reasoning_effort
input.
required: false
default: '{"thinking": {"type": "disabled"}}'
llm_reasoning_effort:
description: >-
Reasoning depth for the model (one of: minimal, low, medium, high, max;
case-insensitive), merged into the request body as reasoning_effort via
llm.extra_body — no CLI support beyond the published versions is needed.
OpenAI-compatible protocols only (e.g. GLM-5.x, OpenAI reasoning
models); the Anthropic API rejects unknown body fields, so the action
fails fast when this is set on the Anthropic protocol — steer Anthropic
thinking through an explicit llm_extra_body key instead. Empty
(default) sends nothing. An explicit reasoning_effort key in
llm_extra_body wins over this input.
required: false
default: ''
language:
description: >-
Review output language, written via `ocr config set language`
(e.g. English, Chinese). No env var exists for this.
required: false
default: 'English'
llm_timeout:
description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT).
required: false
default: '300'
review_task_timeout:
description: Per-file/concurrent-task timeout in integer minutes, from 1 through 120.
required: false
default: '15'
github_token:
description: GitHub token used to post review comments.
required: false
default: ${{ github.token }}
ocr_version:
description: npm version spec for @alibaba-group/open-code-review (v1.9.6 or newer required).
required: false
default: latest
review_concurrency:
description: Value passed to `ocr review --concurrency`.
required: false
background:
description: Value passed to `ocr review --background`.
required: false
rule:
description: Path to a custom rules JSON file passed to `ocr review --rule`.
required: false
effort:
description: >-
Review effort preset passed to `ocr review --effort`. One of: low,
medium, high (case-insensitive). Empty keeps the CLI default (the
configured value, or medium). Requires OpenCodeReview v1.10.0 or newer.
required: false
default: ''
max_tokens_budget:
description: >-
Total token cap passed to `ocr review --max-tokens-budget`. Base-10
integer; empty or 0 means unlimited. Once the cap is exceeded, dispatch
stops, skipped files are reported as failed(budget), partial results
are still published, and the review exits 0.
required: false
default: ''
stream_progress:
description: >-
Stream live [ocr] review progress to the workflow log (human audience
on stderr) instead of staying silent until the run finishes. One of:
true, false (case-insensitive); empty falls back to false. false
(default) keeps the silent agent-audience run with stderr captured to
a log file. Requires OpenCodeReview v1.9.8 or newer when enabled.
required: false
default: 'false'
upload_artifacts:
description: >-
Upload raw JSON result and stderr as workflow artifacts. Must be the
literal string 'true' or 'false' (quoted); the step gates on a string
comparison, so an unquoted YAML boolean will not match.
required: false
default: 'true'
sticky_summary:
description: >-
Summary dimension. true = update an existing summary comment in place
(sticky) instead of posting a new one each run.
required: false
default: 'true'
incremental:
description: >-
Incremental dimension. true = only append inline comments whose (path,
line range) does not overlap an existing bot review comment. History is
never deleted (non-destructive).
required: false
default: 'false'
incremental_overlap_threshold:
description: >-
IoU (intersection-over-union) threshold used by incremental mode to decide
whether a new multi-line comment overlaps an existing one. Two single-line
comments match when on the same line; single- vs multi-line never match.
Value in (0, 1]; ignored unless incremental is true.
required: false
default: '0.6'
review_comment_batch_size:
description: >-
Maximum number of inline comments packed into a single createReview call.
Large reviews are split into sequential batches of at most this size so a
single request never exceeds GitHub's practical limits (a prior run failed
after posting 71 inline comments in one request). Integer >= 1; values
below 1 or non-numeric fall back to the default (50).
required: false
default: '50'
route_severity_below:
description: >-
Optional severity threshold that routes findings at-or-below it from
inline comments to the PR summary (fail-open: never drops a finding).
One of: critical, high, medium, low. For example, 'low' routes only
low-severity findings, while 'medium' routes medium AND low. An empty or
unknown value disables severity routing (findings stay inline). Findings
with an empty or unknown severity are never routed by this policy and
keep their normal placement.
required: false
default: ''
route_categories:
description: >-
Optional comma-separated list of categories routed from inline comments
to the PR summary (fail-open: never drops a finding). Categories are
case-insensitive and drawn from: bug, security, performance,
maintainability, test, style, documentation, other. For example,
'style,documentation' routes those categories to the summary. Unknown
category tokens are ignored. Findings with an empty or unknown category
are never routed by this policy and keep their normal placement. Combine
with route_severity_below to route on either condition.
required: false
default: ''
checkpoint_range:
description: >-
Cross-push checkpoints. true = a run that reviewed everything it selected
records the head it covered in its sticky summary comment, and the next
run reviews only <checkpoint>..<new head> instead of
<merge-base>..<new head>. Fail-closed: if anything is in doubt — the
summary is missing or was not posted by this token, the marker is
unreadable, the base moved, the configuration changed, or git cannot prove
the checkpoint is an ancestor of the new head — the full range is reviewed
exactly as it is today. Requires sticky_summary; ignored without it.
required: false
default: 'false'
full_review:
description: >-
Force one full review even when checkpoint_range is enabled (reason
'manual_full_review'). Use it to re-review a PR from the merge-base
without turning checkpointing off; the run still records a new checkpoint.
required: false
default: 'false'
base_ref:
description: >-
Override the base ref. Provide this (and head_sha) when invoking from a
non-PR event such as issue_comment.
required: false
head_sha:
description: Override the head commit SHA (use with base_ref for comment triggers).
required: false
node_version:
description: Node.js version for actions/setup-node.
required: false
default: '24'
outputs:
comments_total:
description: Total number of review comments generated by OCR.
value: ${{ steps.post.outputs.comments_total }}
comments_inline:
description: Number of inline comments successfully posted.
value: ${{ steps.post.outputs.comments_inline }}
comments_skipped:
description: Number of inline comments skipped by incremental mode (overlap with history).
value: ${{ steps.post.outputs.comments_skipped }}
comments_routed:
description: >-
Number of inline-eligible comments routed to the PR summary by the
route_severity_below / route_categories policy. Mutually exclusive with
comments_inline, comments_skipped, and comments_failed.
value: ${{ steps.post.outputs.comments_routed }}
comments_failed:
description: Number of inline comments that failed to post.
value: ${{ steps.post.outputs.comments_failed }}
summary_comment_url:
description: URL of the posted/updated summary comment, if any.
value: ${{ steps.post.outputs.summary_comment_url }}
range_mode:
description: >-
'checkpoint' when this run reviewed only the range since the previous
checkpoint, 'full' when it reviewed from the merge-base. Empty when
checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_mode }}
range_summary:
description: >-
The reviewed range plus the reason it was chosen, e.g.
"full (base_changed)" or "checkpoint (ok): <from>..<to>". Empty when
checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_summary }}
range_reason:
description: >-
Why that mode was chosen: 'ok', 'same_head_noop', or one of the
fail-closed reasons ('base_changed', 'config_changed', 'not_ancestor', …).
Empty when checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_reason }}
range_from:
description: >-
The commit this run reviewed from, or empty when it reviewed from the
merge-base (every 'full' run, and every run with checkpoint_range off).
value: ${{ steps.range.outputs.range_from }}
range_to:
description: The head this run reviewed up to. Empty when checkpoint_range is not enabled.
value: ${{ steps.range.outputs.range_to }}
checkpoint_before:
description: >-
The head recorded by the checkpoint this run read, whether or not it was
used. Empty when no usable marker was read.
value: ${{ steps.range.outputs.checkpoint_before }}
ancestry:
description: >-
The ancestry verdict on the checkpoint that was read: 'ancestor',
'not_ancestor', 'unknown_object', 'error', or empty when it was not probed.
value: ${{ steps.range.outputs.ancestry }}
source_run:
description: The workflow run id that wrote the checkpoint this run read, if any.
value: ${{ steps.range.outputs.source_run }}
checkpoint_after:
description: >-
The head this run recorded as the new checkpoint, or empty when it did not
advance one (incomplete run, a finding failed to post, or the summary did
not publish).
value: ${{ steps.post.outputs.checkpoint_after }}
runs:
using: composite
steps:
- name: Check git and Node.js
id: check_deps
shell: bash
run: |
if command -v git >/dev/null 2>&1; then
echo "git_installed=true" >> "$GITHUB_OUTPUT"
echo "git is already installed: $(git --version)"
else
echo "git_installed=false" >> "$GITHUB_OUTPUT"
echo "git is not installed"
fi
if command -v node >/dev/null 2>&1; then
echo "node_installed=true" >> "$GITHUB_OUTPUT"
echo "node is already installed: $(node --version)"
else
echo "node_installed=false" >> "$GITHUB_OUTPUT"
echo "node is not installed"
fi
- name: Install git
if: steps.check_deps.outputs.git_installed != 'true'
shell: bash
run: |
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y git
elif command -v brew >/dev/null 2>&1; then
brew install git
elif command -v yum >/dev/null 2>&1; then
sudo yum install -y git
elif command -v apk >/dev/null 2>&1; then
sudo apk add --no-cache git
else
echo "::error::Unable to install git: no supported package manager found"
exit 1
fi
git --version
- name: Setup Node.js
if: steps.check_deps.outputs.node_installed != 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node_version }}
- name: Resolve PR refs
shell: bash
env:
INPUT_BASE_REF: ${{ inputs.base_ref }}
INPUT_HEAD_SHA: ${{ inputs.head_sha }}
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}"
echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV"
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
echo "PR base ref: $BASE_REF"
echo "PR head sha: $HEAD_SHA"
- name: Checkout base
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Checkout the trusted base, not the PR head. OCR reviews the
# base-to-head diff from git objects; the head commit's blobs are
# fetched separately so they are resolvable without materializing
# untrusted PR files into the working tree.
fetch-depth: 0
- name: Fetch PR head (fork-safe)
if: env.HEAD_SHA != ''
shell: bash
env:
PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
if [ -n "$PR_NUM" ]; then
git fetch origin "pull/${PR_NUM}/head"
fi
- name: Compute merge-base
shell: bash
run: |
git fetch origin "${BASE_REF}" 2>/dev/null || true
MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}")
echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV"
echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})"
- name: Validate inputs
env:
REVIEW_TASK_TIMEOUT: ${{ inputs.review_task_timeout }}
EFFORT_INPUT: ${{ inputs.effort }}
MAX_TOKENS_BUDGET_INPUT: ${{ inputs.max_tokens_budget }}
LLM_REASONING_EFFORT_INPUT: ${{ inputs.llm_reasoning_effort }}
STREAM_PROGRESS_INPUT: ${{ inputs.stream_progress }}
shell: bash
run: |
if [[ ! "$REVIEW_TASK_TIMEOUT" =~ ^[0-9]+$ ]]; then
echo "::error::review_task_timeout must be a base-10 integer from 1 through 120 minutes"
exit 1
fi
NORMALIZED_REVIEW_TASK_TIMEOUT="$REVIEW_TASK_TIMEOUT"
while [[ "$NORMALIZED_REVIEW_TASK_TIMEOUT" == 0* && "$NORMALIZED_REVIEW_TASK_TIMEOUT" != "0" ]]; do
NORMALIZED_REVIEW_TASK_TIMEOUT="${NORMALIZED_REVIEW_TASK_TIMEOUT#0}"
done
if [[ "$NORMALIZED_REVIEW_TASK_TIMEOUT" == "0" || ${#NORMALIZED_REVIEW_TASK_TIMEOUT} -gt 3 ]]; then
echo "::error::review_task_timeout must be between 1 and 120 minutes"
exit 1
fi
REVIEW_TASK_TIMEOUT_NUMBER=$((10#$NORMALIZED_REVIEW_TASK_TIMEOUT))
if (( REVIEW_TASK_TIMEOUT_NUMBER < 1 || REVIEW_TASK_TIMEOUT_NUMBER > 120 )); then
echo "::error::review_task_timeout must be between 1 and 120 minutes"
exit 1
fi
echo "REVIEW_TASK_TIMEOUT=$REVIEW_TASK_TIMEOUT_NUMBER" >> "$GITHUB_ENV"
# Empty keeps the CLI default; otherwise one of the named presets.
# Normalized to lowercase so the Run step appends a value `ocr review
# --effort` accepts verbatim.
NORMALIZED_EFFORT="$(printf '%s' "$EFFORT_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_EFFORT" in
""|low|medium|high) ;;
*)
echo "::error::effort must be one of: low, medium, high (got '$EFFORT_INPUT')"
exit 1
;;
esac
echo "EFFORT=$NORMALIZED_EFFORT" >> "$GITHUB_ENV"
# Empty or 0 means unlimited; both normalize to empty so the Run step
# simply omits the flag.
NORMALIZED_MAX_TOKENS_BUDGET="$MAX_TOKENS_BUDGET_INPUT"
if [[ -n "$NORMALIZED_MAX_TOKENS_BUDGET" ]]; then
if [[ ! "$NORMALIZED_MAX_TOKENS_BUDGET" =~ ^[0-9]+$ ]]; then
echo "::error::max_tokens_budget must be a base-10 integer >= 0 (empty or 0 = unlimited)"
exit 1
fi
while [[ "$NORMALIZED_MAX_TOKENS_BUDGET" == 0* && "$NORMALIZED_MAX_TOKENS_BUDGET" != "0" ]]; do
NORMALIZED_MAX_TOKENS_BUDGET="${NORMALIZED_MAX_TOKENS_BUDGET#0}"
done
if [[ "$NORMALIZED_MAX_TOKENS_BUDGET" == "0" ]]; then
NORMALIZED_MAX_TOKENS_BUDGET=""
fi
fi
echo "MAX_TOKENS_BUDGET=$NORMALIZED_MAX_TOKENS_BUDGET" >> "$GITHUB_ENV"
# Empty leaves the request body untouched; otherwise the union of the
# OpenAI and GLM vocabularies, normalized to lowercase for the
# extra_body injection in the Configure OCR step (which uses node,
# guaranteed on PATH by the Actions runtime).
NORMALIZED_LLM_REASONING_EFFORT="$(printf '%s' "$LLM_REASONING_EFFORT_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_LLM_REASONING_EFFORT" in
""|minimal|low|medium|high|max) ;;
*)
echo "::error::llm_reasoning_effort must be one of: minimal, low, medium, high, max (got '$LLM_REASONING_EFFORT_INPUT')"
exit 1
;;
esac
echo "LLM_REASONING_EFFORT=$NORMALIZED_LLM_REASONING_EFFORT" >> "$GITHUB_ENV"
# Display-only toggle: 'true' streams human-audience progress lines to
# the workflow log, 'false' keeps the silent agent-audience run. It
# changes nothing the review would say, so it stays out of the
# checkpoint config fingerprint.
NORMALIZED_STREAM_PROGRESS="$(printf '%s' "$STREAM_PROGRESS_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_STREAM_PROGRESS" in
"") NORMALIZED_STREAM_PROGRESS=false ;;
true|false) ;;
*)
echo "::error::stream_progress must be one of: true, false (got '$STREAM_PROGRESS_INPUT')"
exit 1
;;
esac
echo "STREAM_PROGRESS=$NORMALIZED_STREAM_PROGRESS" >> "$GITHUB_ENV"
- name: Install OpenCodeReview
shell: bash
env:
OCR_VERSION: ${{ inputs.ocr_version }}
run: |
npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}"
echo "OpenCodeReview installed:"
OCR_VERSION_OUTPUT="$(ocr version 2>/dev/null)" || {
echo "::error::Unable to read the installed OpenCodeReview version"
exit 1
}
printf '%s\n' "$OCR_VERSION_OUTPUT"
OCR_VERSION_LINE="${OCR_VERSION_OUTPUT%%$'\n'*}"
if [[ ! "$OCR_VERSION_LINE" =~ ^open-code-review[[:space:]]+v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?([[:space:]]|$) ]]; then
echo "::error::Unable to parse the installed OpenCodeReview version; a stable release v1.9.6 or newer is required (prerelease versions are not supported)"
exit 1
fi
OCR_VERSION_MAJOR=$((10#${BASH_REMATCH[1]}))
OCR_VERSION_MINOR=$((10#${BASH_REMATCH[2]}))
OCR_VERSION_PATCH=$((10#${BASH_REMATCH[3]}))
if (( OCR_VERSION_MAJOR < 1 )) ||
(( OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR < 9 )) ||
(( OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR == 9 && OCR_VERSION_PATCH < 6 )); then
echo "::error::Installed OpenCodeReview ${OCR_VERSION_LINE} is unsupported; v1.9.6 or newer is required"
exit 1
fi
# --effort was introduced in v1.10.0; reject an explicit effort input
# on older releases instead of letting the CLI fail on an unknown flag.
# max_tokens_budget predates the v1.9.6 floor and llm_reasoning_effort
# rides extra_body, so neither needs a gate.
if [ -n "${EFFORT:-}" ] &&
(( OCR_VERSION_MAJOR < 1 || (OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR < 10) )); then
echo "::error::The effort input requires OpenCodeReview v1.10.0 or newer"
exit 1
fi
# Human-audience progress only moved to stderr in v1.9.8 (66d71b2); on
# older releases stream_progress drops --audience agent and progress
# lines interleave into the stdout JSON, corrupting the result file.
if [ "${STREAM_PROGRESS:-false}" = "true" ] &&
(( OCR_VERSION_MAJOR < 1 || (OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR < 9) ||
(OCR_VERSION_MAJOR == 1 && OCR_VERSION_MINOR == 9 && OCR_VERSION_PATCH < 8) )); then
echo "::error::The stream_progress input requires OpenCodeReview v1.9.8 or newer"
exit 1
fi
# Resolved version (not the spec, which is usually "latest"). It feeds
# the checkpoint fingerprint so an OCR upgrade invalidates checkpoints
# taken by the previous version. The gate above already guarantees a
# parseable line here; the resolve step still treats an empty version
# as "fingerprint nothing" so a future gap can't silently narrow.
VERSION_ACTUAL="${OCR_VERSION_LINE//$'\r'/}"
echo "OCR_VERSION_ACTUAL=${VERSION_ACTUAL}" >> "$GITHUB_ENV"
- name: Configure OCR
env:
OCR_LLM_URL: ${{ inputs.llm_url }}
OCR_LLM_MODEL: ${{ inputs.llm_model }}
OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}
OCR_LANGUAGE: ${{ inputs.language }}
shell: bash
run: |
NORMALIZED_USE_ANTHROPIC="$(printf '%s' "$OCR_USE_ANTHROPIC" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_USE_ANTHROPIC" in
""|true|1|yes)
OCR_USE_ANTHROPIC="true"
OCR_LLM_PROTOCOL="anthropic"
;;
*)
OCR_USE_ANTHROPIC="false"
OCR_LLM_PROTOCOL="openai"
;;
esac
# reasoning_effort is OpenAI-compatible vocabulary; the Anthropic API
# rejects unknown body fields, so fail fast instead of breaking every
# request. Anthropic thinking control goes through an explicit
# llm_extra_body key instead.
if [ -n "${LLM_REASONING_EFFORT:-}" ] && [ "$OCR_LLM_PROTOCOL" = "anthropic" ]; then
echo "::error::llm_reasoning_effort is supported only with OpenAI-compatible protocols"
exit 1
fi
ocr config unset provider
ocr config set llm.auth_token ""
ocr config set llm.extra_headers ""
ocr config set llm.retry_codes ""
ocr config set llm.url "$OCR_LLM_URL"
ocr config set llm.model "$OCR_LLM_MODEL"
ocr config set llm.use_anthropic "$OCR_USE_ANTHROPIC"
ocr config set llm.protocol "$OCR_LLM_PROTOCOL"
ocr config set llm.auth_header "$OCR_LLM_AUTH_HEADER"
ocr config set llm.auth_token_cmd 'printf "%s" "$OCR_LLM_TOKEN"'
# reasoning_effort rides the existing extra_body merge, so any
# published CLI version supports it. An explicit reasoning_effort key
# in llm_extra_body wins over the input — the hand-written body is the
# more specific intent. The value was validated and normalized by the
# Validate inputs step. node (not jq) does the merge: the Actions
# runtime guarantees node on PATH even inside container jobs, while jq
# is only preinstalled on GitHub-hosted images. An explicitly empty
# extra_body parses as an empty object so the merge still lands.
EFFECTIVE_EXTRA_BODY="$OCR_EXTRA_BODY"
if [ -n "${LLM_REASONING_EFFORT:-}" ]; then
EFFECTIVE_EXTRA_BODY="$(printf '%s' "$EFFECTIVE_EXTRA_BODY" | node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk)).on("end", () => {
let body;
try {
body = raw ? JSON.parse(raw) : {};
} catch (error) {
// stderr escapes the command substitution, so the workflow
// command reaches the log; the non-zero exit fails the step.
console.error(`::error::llm_extra_body is not valid JSON: ${error.message}`);
process.exit(1);
}
if (body === null || typeof body !== "object" || Array.isArray(body)) {
console.error("::error::llm_extra_body must be a JSON object");
process.exit(1);
}
if (body.reasoning_effort === undefined) body.reasoning_effort = process.env.LLM_REASONING_EFFORT;
process.stdout.write(JSON.stringify(body));
});')"
fi
ocr config set llm.extra_body "$EFFECTIVE_EXTRA_BODY"
ocr config set language "$OCR_LANGUAGE"
- name: Resolve review range
if: inputs.checkpoint_range == 'true'
id: range
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
OCR_FULL_REVIEW: ${{ inputs.full_review }}
OCR_STICKY_SUMMARY: ${{ inputs.sticky_summary }}
# "reopened" / "ready_for_review" ask for a fresh look at the whole PR.
OCR_EVENT_ACTION: ${{ github.event.action }}
# Written to $GITHUB_ENV by the "Resolve PR refs" and "Compute
# merge-base" steps above. Declared here rather than read straight off
# the ambient job env so this step's inputs are all in one place and the
# dependency on those two steps is visible in the file. Same values
# either way — a skipped upstream step yields "", which the resolver
# already fails closed on: an empty baseRef/mergeBase cannot equal the
# non-empty ones validateCheckpointPayload requires, so it reports
# base_changed and reviews the full range.
OCR_HEAD_SHA: ${{ env.HEAD_SHA }}
OCR_BASE_REF: ${{ env.BASE_REF }}
OCR_MERGE_BASE: ${{ env.MERGE_BASE }}
# Everything that changes what a review would say. Any difference
# invalidates the checkpoint, because findings from the previous run are
# no longer comparable to what this configuration would produce.
#
# One variable per axis, joined in the script rather than here. A single
# pre-joined string would make the digest depend on two things it has no
# business depending on: how this YAML happens to be wrapped (a folded
# scalar re-wrapped by a reformat is a different string), and on no input
# value ever containing the separator — and a value that does contain it
# shifts the field boundaries, so two different configurations can hash
# alike and a checkpoint survives a change that should have killed it.
OCR_FP_LLM_URL: ${{ inputs.llm_url }}
OCR_FP_LLM_MODEL: ${{ inputs.llm_model }}
OCR_FP_LLM_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
OCR_FP_LANGUAGE: ${{ inputs.language }}
OCR_FP_LLM_EXTRA_BODY: ${{ inputs.llm_extra_body }}
# Normalized by Validate inputs, so spellings that mean the same thing
# (HIGH vs high, '0' vs '' vs '00') hash identically and keep the
# checkpoint.
OCR_FP_LLM_REASONING_EFFORT: ${{ env.LLM_REASONING_EFFORT }}
OCR_FP_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
# A timeout change shifts which runs finish and which are cut short, so
# it shifts the partial/complete distribution the checkpoint gates on.
OCR_FP_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
# Effort shifts how many rounds a review runs and the budget caps how
# much it may spend; both change what a review would say, so both
# invalidate the checkpoint. Both read the values Validate inputs
# normalized into the environment, not the raw inputs.
OCR_FP_EFFORT: ${{ env.EFFORT }}
OCR_FP_MAX_TOKENS_BUDGET: ${{ env.MAX_TOKENS_BUDGET }}
# Extra headers can point the same `llm_model` string at a different
# backend model or a different provider entirely, so they change what a
# review would say and must invalidate the checkpoint. Their VALUES can
# be credentials, so the script hashes this axis before it joins the
# others — the same treatment the rule file contents get. Only the
# digest reaches the fingerprint, and the fingerprint is all the stored
# checkpoint ever carries.
OCR_FP_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
OCR_FP_RULE: ${{ inputs.rule }}
OCR_FP_ROUTE_SEVERITY_BELOW: ${{ inputs.route_severity_below }}
OCR_FP_ROUTE_CATEGORIES: ${{ inputs.route_categories }}
OCR_FP_BACKGROUND: ${{ inputs.background }}
OCR_RULE_PATH: ${{ inputs.rule }}
# `github.token` is always the "github-actions" app, so when the caller
# did not override the token we know exactly which app wrote our summary
# and can pin the author check to it instead of trusting any bot. A
# custom token may belong to any app and an installation token cannot
# ask GitHub which app it is (GET /app needs a JWT), so that case keeps
# the wider "any writer GitHub attributes to a bot" check.
OCR_CHECKPOINT_APP_SLUG: ${{ inputs.github_token == github.token && 'github-actions' || '' }}
with:
github-token: ${{ inputs.github_token }}
script: |
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
// Everything this step publishes, in one place so the failure path
// below can publish the same shape. Empty range_from means "review the
// full range": the review step expands ${RANGE_FROM:-$MERGE_BASE}, so
// unset and empty behave alike. These are STEP outputs, not job env:
// a second use of this action in the same job must not inherit a
// narrowed range from the first one.
const emit = (range, fingerprint, carry) => {
const summary = range.mode === 'checkpoint'
? `checkpoint (${range.reason}): ${range.from}..${range.to}`
: `full (${range.reason})`;
core.setOutput('range_from', range.mode === 'checkpoint' ? range.from : '');
core.setOutput('range_to', range.to || '');
core.setOutput('range_mode', range.mode);
core.setOutput('range_reason', range.reason);
core.setOutput('range_summary', summary);
core.setOutput('checkpoint_before', range.checkpointBefore || '');
core.setOutput('ancestry', range.ancestry || '');
core.setOutput('source_run', range.sourceRun || '');
core.setOutput('config_fingerprint', fingerprint);
core.setOutput('checkpoint_carry', carry);
core.info(`[checkpoint] reviewing ${summary}`);
};
// This step only chooses where the review starts, and every failure it
// can hit has the same safe answer: review the whole merge-base range.
// So nothing in here may fail the job — a missing helper or an API
// outage must not block a review the action can still perform.
try {
// Same helper lookup as the posting step below.
const REL = 'scripts/github-actions/post-review-comments.js';
const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean);
const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p));
if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`);
const { resolveCheckpointRange, readCheckpointComment } = require(helper);
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
let ruleUnverified = false;
// `rule` names a JSON file that OCR reads off the workspace at review
// time (rules.NewResolver only touches disk when the path is non-empty;
// the default rule set is embedded in the binary and so already moves
// with OCR_VERSION_ACTUAL). Fingerprinting the *path* alone would let an
// edit to that file narrow the next range under rules the earlier
// commits were never reviewed against, so hash the contents too.
let ruleDigest = 'none';
const rulePath = process.env.OCR_RULE_PATH || '';
if (rulePath) {
try {
ruleDigest = sha256(fs.readFileSync(path.resolve(process.env.GITHUB_WORKSPACE || '.', rulePath)));
} catch (e) {
// Cannot prove the rules are unchanged -> do not narrow. OCR itself
// would normally have failed on an unreadable rule file before this
// step runs, so this is a belt-and-braces path.
ruleUnverified = true;
core.warning(`checkpoint: cannot read rule file ${rulePath} (${e.message}); forcing a full review.`);
}
}
// .opencodereview/rule.json is loaded from the repo whether or not
// `rule` is set (rules.NewResolver reads <repoDir>/.opencodereview/rule.json
// at high priority), so a commit that edits it changes what a review
// says and must invalidate the checkpoint the same way.
let localRuleDigest = 'none';
const localRulePath = path.resolve(process.env.GITHUB_WORKSPACE || '.', '.opencodereview/rule.json');
if (fs.existsSync(localRulePath)) {
try {
localRuleDigest = sha256(fs.readFileSync(localRulePath));
} catch (e) {
ruleUnverified = true;
core.warning(`checkpoint: cannot read .opencodereview/rule.json (${e.message}); forcing a full review.`);
}
}
// Header values can be credentials, so this axis goes in as a
// digest. Same shape as ruleDigest: 'none' when unset, so "no extra
// headers" and "extra headers that hash to nothing" stay distinct.
const extraHeaders = process.env.OCR_FP_LLM_EXTRA_HEADERS || '';
const extraHeadersDigest = extraHeaders ? sha256(extraHeaders) : 'none';
// `ocr version` printed nothing, so this run cannot say which OCR
// version it is about to use and no stored fingerprint can be
// trusted to mean "same version". An empty fingerprint is the
// honest answer: it matches no stored one (-> config_changed, full
// review) and the posting step refuses to record a checkpoint
// without it. Don't narrow, don't advance.
const versionActual = process.env.OCR_VERSION_ACTUAL || '';
if (!versionActual) {
core.warning('checkpoint: `ocr version` printed no version; reviewing the full range and not recording a checkpoint.');
}
// JSON, not a joined string: it is length-delimited, so no input
// value can shift a field boundary and make two different
// configurations hash alike.
const fingerprint = !versionActual ? '' : crypto.createHash('sha256')
.update(JSON.stringify([
process.env.OCR_FP_LLM_URL,
process.env.OCR_FP_LLM_MODEL,
process.env.OCR_FP_LLM_USE_ANTHROPIC,
process.env.OCR_FP_LANGUAGE,
process.env.OCR_FP_LLM_EXTRA_BODY,
process.env.OCR_FP_LLM_REASONING_EFFORT,
process.env.OCR_FP_LLM_AUTH_HEADER,
process.env.OCR_FP_LLM_TIMEOUT,
process.env.OCR_FP_EFFORT,
process.env.OCR_FP_MAX_TOKENS_BUDGET,
extraHeadersDigest,
process.env.OCR_FP_RULE,
process.env.OCR_FP_ROUTE_SEVERITY_BELOW,
process.env.OCR_FP_ROUTE_CATEGORIES,
process.env.OCR_FP_BACKGROUND,
versionActual,
ruleDigest,
localRuleDigest,
].map((v) => v || '')))
.digest('hex')
.slice(0, 16);
// git's own ancestry verdict: 0 = ancestor, 1 = not, 128 = the object
// is not in this clone (shallow fetch, force-push, head_sha override).
// Anything else (git missing, signal) is a resolver error. Never
// treated as "ancestor" except on a literal 0.
const isAncestor = (a, b) =>
spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: process.env.GITHUB_WORKSPACE }).status;
const common = {
github,
owner: context.repo.owner,
repo: context.repo.repo,
prNumber: context.issue.number,
// Non-empty only when the token is the default one, whose app
// identity is known; empty keeps the wider bot check.
appSlug: process.env.OCR_CHECKPOINT_APP_SLUG || '',
log: (m) => core.info(m),
};
// One read serves both purposes: the range decision, and the verbatim
// marker the posting step re-emits on a run that does not advance the
// checkpoint (the summary body is rewritten wholesale, which would
// otherwise erase it). Passing it into the resolver as `read` is what
// keeps this to a single listComments pagination per run.
const existing = await readCheckpointComment(common);
const range = await resolveCheckpointRange(Object.assign({}, common, {
read: existing,
enabled: true,
sticky: process.env.OCR_STICKY_SUMMARY === 'true',
fullReview: process.env.OCR_FULL_REVIEW === 'true',
eventAction: process.env.OCR_EVENT_ACTION || '',
headSha: process.env.OCR_HEAD_SHA || '',
baseRef: process.env.OCR_BASE_REF || '',
mergeBase: process.env.OCR_MERGE_BASE || '',
fingerprint,
isAncestor,
}));
// Last gate, applied after the ordered ones in the resolver: the rules
// this run will apply could not be read, so no stored fingerprint can
// be trusted to mean "same rules". Widening is always safe; narrowing
// is not.
if (ruleUnverified && range.mode === 'checkpoint') {
range.mode = 'full';
range.reason = 'rule_unreadable';
}
emit(range, fingerprint, existing.raw || '');
} catch (e) {
core.warning(`checkpoint: could not resolve a range (${e.message}); reviewing the full range.`);
emit({ mode: 'full', reason: 'resolver_error', from: '', to: process.env.OCR_HEAD_SHA || '' }, '', '');
}
- name: Run OpenCodeReview
env:
OCR_LLM_URL: ${{ inputs.llm_url }}
OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }}
OCR_LLM_MODEL: ${{ inputs.llm_model }}
OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }}
OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }}
OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }}
OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }}
OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }}
OCR_BACKGROUND: ${{ inputs.background }}
OCR_RULE: ${{ inputs.rule }}
# Step output, not job env: empty when the resolve step was skipped or
# chose the full range, and never inherited from an earlier use of this
# action in the same job.
RANGE_FROM: ${{ steps.range.outputs.range_from }}
shell: bash
run: |
export OCR_LLM_TIMEOUT="${OCR_LLM_TIMEOUT:-300}"
if [ -z "${REVIEW_TASK_TIMEOUT:-}" ]; then
echo "::error::Validated review_task_timeout is missing; the Validate inputs step must complete first"
exit 1
fi
ARGS=(--from "${RANGE_FROM:-$MERGE_BASE}" --to "${HEAD_SHA}")
# stream_progress 'false' (default): agent audience, stderr captured to
# the log file without a live tee. Opt-in 'true': no --audience agent,
# so human-audience routing sends [ocr] progress lines to stderr; a
# FIFO feeds a background tee so progress streams live into the
# workflow log while still landing in the file for artifacts and the
# posting step. The tee is a real background job rather than process
# substitution so `wait` below guarantees the file is fully flushed
# before anything reads it. Neither mode disturbs $?.
[ "${STREAM_PROGRESS:-false}" = "true" ] || ARGS+=(--audience agent)
ARGS+=(--format json --timeout "$REVIEW_TASK_TIMEOUT")
[ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY")
[ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")
[ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE")
[ -n "${EFFORT:-}" ] && ARGS+=(--effort "$EFFORT")
[ -n "${MAX_TOKENS_BUDGET:-}" ] && ARGS+=(--max-tokens-budget "$MAX_TOKENS_BUDGET")
set +e
if [ "${STREAM_PROGRESS:-false}" = "true" ]; then
OCR_STDERR_FIFO="$(mktemp -u)"
mkfifo "$OCR_STDERR_FIFO" || exit 1
tee /tmp/ocr-stderr.log < "$OCR_STDERR_FIFO" >&2 &
TEE_PID=$!
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2> "$OCR_STDERR_FIFO"
OCR_EXIT_CODE=$?
wait "$TEE_PID"
rm -f "$OCR_STDERR_FIFO"
else
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log
OCR_EXIT_CODE=$?
fi
set -e
echo "OCR_EXIT_CODE=$OCR_EXIT_CODE" >> "$GITHUB_ENV"
echo "=== OCR result ==="
cat /tmp/ocr-result.json
echo "=== OCR stderr ==="
cat /tmp/ocr-stderr.log
- name: Upload review artifacts
if: ${{ always() && inputs.upload_artifacts == 'true' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ocr-review-result-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/ocr-result.json
/tmp/ocr-stderr.log
if-no-files-found: warn
- name: Fail job on OCR error
if: env.OCR_EXIT_CODE != '0'
shell: bash
run: |
echo "ocr review exited with code ${OCR_EXIT_CODE}; see uploaded artifacts for details."
exit "${OCR_EXIT_CODE}"
- name: Post review comments
if: env.OCR_EXIT_CODE == '0'
id: post
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }}
OCR_REVIEW_COMMENT_BATCH_SIZE: ${{ inputs.review_comment_batch_size }}
OCR_ROUTE_SEVERITY_BELOW: ${{ inputs.route_severity_below }}
OCR_ROUTE_CATEGORIES: ${{ inputs.route_categories }}
# Outputs of the Resolve review range step; all empty when that step was
# skipped (checkpointing off), which is what turns the checkpoint write
# path back into today's behavior.
OCR_CHECKPOINT_CARRY: ${{ steps.range.outputs.checkpoint_carry }}
OCR_CONFIG_FINGERPRINT: ${{ steps.range.outputs.config_fingerprint }}
OCR_RANGE_MODE: ${{ steps.range.outputs.range_mode }}
OCR_RANGE_FROM: ${{ steps.range.outputs.range_from }}
OCR_RANGE_TO: ${{ steps.range.outputs.range_to }}
OCR_RANGE_REASON: ${{ steps.range.outputs.range_reason }}
OCR_BASE_REF: ${{ env.BASE_REF }}
OCR_MERGE_BASE: ${{ env.MERGE_BASE }}
with:
github-token: ${{ inputs.github_token }}
script: |
// Locate the helper shipped alongside action.yml at runtime.
// GITHUB_ACTION_PATH: correct for published (remote) actions and for
// local actions when not running in a container.
// GITHUB_WORKSPACE: correct for local `uses: ./` actions — under
// self-hosted + container setups, GITHUB_ACTION_PATH points to the
// host path (invisible inside the container), whereas GITHUB_WORKSPACE
// is correctly mapped to /__w.
const fs = require('fs');
const path = require('path');
const REL = 'scripts/github-actions/post-review-comments.js';
const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean);
const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p));
if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`);
const { runPostReviewComments } = require(helper);
await runPostReviewComments({
github,
context,
core,
fs,
resultPath: '/tmp/ocr-result.json',
stderrPath: '/tmp/ocr-stderr.log',
stickySummary: ${{ inputs.sticky_summary == 'true' }},
incremental: ${{ inputs.incremental == 'true' }},
incrementalOverlapThreshold: parseFloat(process.env.OCR_INCREMENTAL_OVERLAP_THRESHOLD),
reviewCommentBatchSize: parseInt(process.env.OCR_REVIEW_COMMENT_BATCH_SIZE, 10),
routeSeverityBelow: process.env.OCR_ROUTE_SEVERITY_BELOW,
routeCategories: process.env.OCR_ROUTE_CATEGORIES,
checkpointEnabled: ${{ inputs.checkpoint_range == 'true' }},
checkpointCarry: process.env.OCR_CHECKPOINT_CARRY || '',
checkpointBaseRef: process.env.OCR_BASE_REF || '',
checkpointMergeBase: process.env.OCR_MERGE_BASE || '',
checkpointFingerprint: process.env.OCR_CONFIG_FINGERPRINT || '',
// Nothing new was in range, so leave the previous run's summary alone
// instead of rewriting it into "No comments generated".
checkpointNoop: process.env.OCR_RANGE_REASON === 'same_head_noop',
rangeMode: process.env.OCR_RANGE_MODE || '',
rangeFrom: process.env.OCR_RANGE_FROM || '',
rangeTo: process.env.OCR_RANGE_TO || '',
});