Files
open-code-review/action.yml
T
Micah Goulart 8d57bc9e83 fix(agent): enforce --max-tokens-budget inside a running group (#1248)
* fix(agent): enforce --max-tokens-budget inside a running group

The budget was only checked when the next group was dispatched, so a review with fewer groups than --concurrency could never hit it. Check it before every LLM round, give the over-budget group one final round to submit findings, and report it as failed(budget).

* fix(agent): record the budget when the round gate skips a round

The round gate in executeGroupSubtask breaks out of the round loop when the
aggregate budget is already spent, but it was the only budget stop that did
not set budgetExceeded. A group whose round finished normally — task_done,
findings submitted — and only then tripped the gate left the run reporting
the budget as intact, because no other gate ever looked again. It now records
the stop the same way the in-conversation check does, with a warning naming
the skipped round, the group, and the usage against the cap.

The grace round stays on StopTokenBudget, in answer to the review on this PR.
It is one bounded call whose whole job is to let the model submit findings it
already has; skipping it spends the tokens the run has already paid for and
throws the findings away. What was missing was not the skip but the record,
plus docs that say the cap is checked before every round rather than only at
dispatch — the cli-reference and CI input tables in all five locales now say
so.

Test covers the gate specifically: a group whose round 1 ends in task_done
already over budget must not start round 2, must not get a grace round (it has
nothing unreported), must stay classified completed, and must still report
BudgetExceeded.
2026-09-17 20:30:45 +08:00

1050 lines
52 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. Checked before every LLM round:
a group already over the cap gets one final round to submit findings,
no further groups are dispatched, over-budget and 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]. Read only when incremental is true: resolve_outdated
decides overlap by any shared line, with no threshold, because a missed
overlap there resolves a thread whose finding is still live.
required: false
default: '0.6'
resolve_outdated:
description: >-
Resolve the action's own outdated inline review threads (threads GitHub
has already marked outdated because their lines no longer exist in the
diff). Threads a human replied to, threads already resolved, and threads
whose lines a finding from the current run still covers are never touched.
One of: 'false' (default, does nothing and makes no API calls), 'report'
(log what would be resolved, change nothing — run this first), 'true'
(actually resolve). 'true' requires the calling workflow to grant
`contents: write` in its own `permissions:` block: GitHub gates the
resolve mutation on repository write access, not on the pull-request
scope, so `pull-requests: write` alone returns FORBIDDEN. Without it the
action warns and continues. 'report' issues no mutation, so it cannot
tell you in advance whether 'true' would be permitted.
A thread is covered when a current finding shares ANY line with it; the
incremental_overlap_threshold IoU test is not used here.
required: false
default: 'false'
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
pr_number:
description: >-
Override the pull request number the run fetches and posts to. Defaults to
the number in the event payload (pull_request / pull_request_target /
issue_comment), then to github.event.workflow_run.pull_requests[0].number.
Provide it for a trigger that carries no pull request of its own, or when a
workflow_run payload lists more than one; the action fails before any
review work when no number resolves.
required: false
default: ''
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 }}
comments_resolved:
description: >-
Number of outdated bot review threads resolved this run. Always 0 unless
resolve_outdated is 'true'.
value: ${{ steps.post.outputs.comments_resolved }}
comments_resolved_preview:
description: >-
Number of candidate threads resolve_outdated 'report' mode found. This is
the uncapped count, so it can exceed the 50 threads a single 'true' run
resolves. Always 0 in 'false' and 'true' modes.
value: ${{ steps.post.outputs.comments_resolved_preview }}
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 }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
# workflow_run carries no pull request of its own, so the run that
# triggered it is reachable only through this array. GitHub fills it in
# only for head branches that live in this repository, and it can list
# more than one PR when several share a head; either case is what
# pr_number is for.
WORKFLOW_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
run: |
BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}"
PR_NUMBER="${INPUT_PR_NUMBER:-${EVENT_PR_NUMBER:-$WORKFLOW_RUN_PR_NUMBER}}"
# Fail here rather than after the review: everything downstream posts to
# this number, and an empty one used to reach GitHub as
# /repos/OWNER/REPO/issues//comments — a 404 that discarded a completed
# review's findings.
if [ -z "$PR_NUMBER" ]; then
echo "::error::No pull request number: the '${GITHUB_EVENT_NAME:-unknown}' event payload carries none. Pass the pr_number input; a workflow_run payload names the PR under workflow_run.pull_requests[0].number."
exit 1
fi
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::Resolved pull request number '${PR_NUMBER}' is not a positive integer; pr_number must be the PR's number alone."
exit 1
fi
echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV"
echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
echo "PR base ref: $BASE_REF"
echo "PR head sha: $HEAD_SHA"
echo "PR number: $PR_NUMBER"
- 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: ${{ env.PR_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 }}
RESOLVE_OUTDATED_INPUT: ${{ inputs.resolve_outdated }}
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"
# Thread cleanup mode: 'false' touches nothing and makes no API calls,
# 'report' logs what it would resolve, 'true' resolves. Empty maps to
# 'false' so a caller that passes an unset variable stays opt-in.
NORMALIZED_RESOLVE_OUTDATED="$(printf '%s' "$RESOLVE_OUTDATED_INPUT" | tr '[:upper:]' '[:lower:]')"
case "$NORMALIZED_RESOLVE_OUTDATED" in
"") NORMALIZED_RESOLVE_OUTDATED=false ;;
false|report|true) ;;
*)
echo "::error::resolve_outdated must be one of: false, report, true (got '$RESOLVE_OUTDATED_INPUT')"
exit 1
;;
esac
echo "RESOLVE_OUTDATED=$NORMALIZED_RESOLVE_OUTDATED" >> "$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 }}
# Same origin, but this one cannot arrive empty: "Resolve PR refs" fails
# the job when no PR number resolves, so a checkpoint is always read for
# a known PR rather than for issue //comments.
OCR_PR_NUMBER: ${{ env.PR_NUMBER }}
# 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: Number(process.env.OCR_PR_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 }}
# Resolved by "Resolve PR refs", which fails the job when no number is
# available; context.issue.number resolves nothing on a workflow_run.
OCR_PR_NUMBER: ${{ env.PR_NUMBER }}
OCR_RESOLVE_OUTDATED: ${{ env.RESOLVE_OUTDATED }}
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,
prNumber: Number(process.env.OCR_PR_NUMBER),
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 || '',
resolveOutdated: process.env.OCR_RESOLVE_OUTDATED,
});