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, 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
  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'
  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: ''
  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 }}

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: 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}" --audience agent --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@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 }}
      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,
          });
