mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-09-14 19:59:52 +08:00
0a74720560
* 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>
959 lines
46 KiB
YAML
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 || '',
|
|
});
|