mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-02 09:24:52 +08:00
* 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.
1050 lines
52 KiB
YAML
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,
|
|
});
|