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), });