open-code-review/action.yml
2026-07-09 19:53:23 +08:00

305 lines
11 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: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs
(mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice."
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`.
required: false
default: '{"thinking": {"type": "disabled"}}'
language:
description: >-
Review output language, written via `ocr config set language`
(e.g. English, 中文). 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
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.
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
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'
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_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 }}
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@v4
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@v4
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: 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 || true
- name: Configure OCR
env:
OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }}
OCR_LANGUAGE: ${{ inputs.language }}
shell: bash
run: |
ocr config set llm.extra_body "$OCR_EXTRA_BODY"
ocr config set language "$OCR_LANGUAGE"
- 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 }}
shell: bash
run: |
ARGS=(--from "${MERGE_BASE}" --to "${HEAD_SHA}" --format json)
[ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY")
[ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")
[ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE")
set +e
ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log
OCR_EXIT_CODE=$?
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@v4
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@v7
env:
OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }}
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),
});