Files

373 lines
19 KiB
YAML

name: AI Code Review
# This file and the engine always run from main; the PR head in ./pr is data only and is never installed, built or run.
on:
pull_request_target:
branches: [main]
# unlabeled and converted_to_draft never review: their run exists only to cancel the one in progress (see concurrency).
types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review, converted_to_draft]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to review"
required: true
type: string
force_full:
description: "Force full review tier (ignore risk assessment)"
required: false
type: boolean
default: false
ignore_paired:
description: "Review here even if a same-named o2-enterprise PR is open"
required: false
type: boolean
default: false
ignore_pass_limit:
description: "Review even if the PR already used its automatic reviews"
required: false
type: boolean
default: false
permissions: {}
# Workflow-level so an unlabeled or draft run, whose job is always skipped, still cancels the paid one; pushes queue so a paid run finishes; unrelated label edits and off-main dispatches, whose job always skips, get a private group and cancel nothing.
concurrency:
group: ai-review-${{ ((contains(fromJSON('["labeled","unlabeled"]'), github.event.action) && github.event.label.name != 'ready-for-ci') || (github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main')) && github.run_id || github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: ${{ contains(fromJSON('["unlabeled","converted_to_draft"]'), github.event.action) }}
jobs:
review:
# Paid CI runs only on non-draft 'ready-for-ci' PRs, and never when vars.AI_REVIEW_ENABLED is 'false' (the kill switch).
# A dispatch from any other ref would run that ref's engine next to the review secrets.
if: >-
${{ vars.AI_REVIEW_ENABLED != 'false' && ((github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (
contains(github.event.pull_request.labels.*.name, 'ready-for-ci') &&
github.event.pull_request.draft == false &&
github.event.action != 'unlabeled' &&
(github.event.action != 'labeled' || github.event.label.name == 'ready-for-ci')
)) }}
# Explicit name: without it GitHub serializes the whole matrix object (api_key_env, marker) into the public check name.
name: review (${{ matrix.provider.name }})
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: ai-review
deployment: false
permissions:
contents: read
pull-requests: write
issues: write
strategy:
fail-fast: false
matrix:
# Markers must not name the model: they ship inside the posted PR comment. Adding a
# provider? Give it a distinct `<!-- ai-code-review-<suffix> -->` marker — run-review.mjs
# matches that shape, so two legs sharing one marker overwrite each other's comment.
provider:
- name: anthropic
provider_id: anthropic-review
model_id: claude-sonnet-5-5
api_key_env: ANTHROPIC_REVIEW_API_KEY
marker: "<!-- ai-code-review -->"
steps:
# Debounce: lets a burst of pushes land so Resolve PR picks up the newest head.
- if: github.event_name == 'pull_request_target'
run: sleep 60
- name: Resolve PR
id: pr
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: |
set -euo pipefail
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::PR number must be numeric"
exit 1
fi
pr_json="$(gh api "repos/$REPO/pulls/$PR_NUMBER")"
state="$(jq -r '.state' <<<"$pr_json")"
head_repo="$(jq -r '.head.repo.full_name // ""' <<<"$pr_json")"
head_sha="$(jq -r '.head.sha' <<<"$pr_json")"
base_sha="$(jq -r '.base.sha' <<<"$pr_json")"
{
echo "number=$PR_NUMBER"
echo "head_sha=$head_sha"
echo "head_ref=$(jq -r '.head.ref' <<<"$pr_json")"
} >> "$GITHUB_OUTPUT"
if [ "$state" != "open" ]; then
echo "PR #$PR_NUMBER is $state; nothing to review."
exit 0
fi
# Fork PRs need the on-request review of v2 (WP-14); until then they are skipped, never run with secrets.
if [ "$head_repo" != "$REPO" ]; then
echo "PR #$PR_NUMBER comes from a fork ($head_repo); skipped."
exit 0
fi
merge_base="$(gh api "repos/$REPO/compare/$base_sha...$head_sha" --jq '.merge_base_commit.sha' || true)"
echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT"
{
echo "author=$(jq -r '.user.login // ""' <<<"$pr_json")"
echo "changed_files=$(jq -r '.changed_files // ""' <<<"$pr_json")"
echo "commit_count=$(jq -r '.commits // ""' <<<"$pr_json")"
} >> "$GITHUB_OUTPUT"
echo "REVIEW_RUN=true" >> "$GITHUB_ENV"
# If minting fails (app not installed, bad key) the steps below fall back to github.token.
- name: Generate OpenObserve Code Reviewer app token
id: app_token
if: env.REVIEW_RUN == 'true' && vars.OO_REVIEWER_APP_ID != ''
continue-on-error: true
# Pinned to a full commit SHA because this action receives the app's signing private key.
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ vars.OO_REVIEWER_APP_ID }}
private-key: ${{ secrets.OO_REVIEWER_APP_PRIVATE_KEY }}
# Without it (app not installed on o2-enterprise, or no actions:write) the PR is reviewed here.
- name: Generate app token for the paired o2-enterprise lookup
id: ent_token
if: env.REVIEW_RUN == 'true' && vars.OO_REVIEWER_APP_ID != '' && !inputs.ignore_paired
continue-on-error: true
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ vars.OO_REVIEWER_APP_ID }}
private-key: ${{ secrets.OO_REVIEWER_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: o2-enterprise
permission-actions: write
permission-pull-requests: read
- name: Check out review engine (trusted, from main)
if: env.REVIEW_RUN == 'true'
uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
path: engine
fetch-depth: 1
persist-credentials: false
sparse-checkout: |
scripts/ai-review
.claude/skills/ui-architect
.claude/skills/eslint-error-handling
# Bills nothing and posts nothing; a hand-started run, a failed listing or a failed check always reviews.
- name: Skip PRs with nothing to review
if: env.REVIEW_RUN == 'true' && github.event_name == 'pull_request_target'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
PR_AUTHOR: ${{ steps.pr.outputs.author }}
# The listings are checked against these, as both endpoints stop at a cap however far they are paged.
EXPECTED_FILES: ${{ steps.pr.outputs.changed_files }}
EXPECTED_COMMITS: ${{ steps.pr.outputs.commit_count }}
run: |
set -uo pipefail
list="$RUNNER_TEMP/pr-files.tsv"
commits="$RUNNER_TEMP/pr-commits.tsv"
# One line per file with its rename source, so code moved under docs/ is not taken for docs and the count can be checked.
if ! gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" --jq '.[] | [.filename, (.previous_filename // "")] | @tsv' > "$list"; then
echo "::warning::Could not list the PR's files; reviewing."
: > "$list"
fi
# One line per commit: author login, committer login, signature verified; a missing field lists as empty and reviews.
if ! gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits?per_page=100" --jq '.[] | [(.author.login // ""), (.committer.login // ""), ((.commit.verification.verified // "") | tostring)] | @tsv' > "$commits"; then
echo "::warning::Could not list the PR's commits; reviewing."
: > "$commits"
fi
reason="$(node engine/scripts/ai-review/skip-check.mjs "$list" "$commits")" || reason=""
if [ -n "$reason" ]; then
echo "::notice::PR #$PR_NUMBER needs no review ($reason); skipped."
echo "REVIEW_RUN=false" >> "$GITHUB_ENV"
fi
# Paired OSS+ENT changes are reviewed once, by the o2-enterprise workflow, which pulls this PR's diff in.
- name: Defer to a paired o2-enterprise PR
if: env.REVIEW_RUN == 'true' && !inputs.ignore_paired
env:
ENT_TOKEN: ${{ steps.ent_token.outputs.token }}
GH_TOKEN: ${{ steps.app_token.outputs.token || github.token }}
BOT_LOGIN: ${{ steps.app_token.outputs.token && format('{0}[bot]', steps.app_token.outputs.app-slug) || 'github-actions[bot]' }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
BRANCH: ${{ steps.pr.outputs.head_ref }}
MARKER: ${{ matrix.provider.marker }}
IGNORE_PASS_LIMIT: ${{ inputs.ignore_pass_limit || 'false' }}
FORCE_REVIEW: ${{ inputs.force_full || 'false' }}
run: |
set -uo pipefail
# An old pointer cannot prove the paired PR is still open, so only a successful lookup may skip the review.
review_here() {
echo "::warning::$1; reviewing here."
exit 0
}
if [ -z "$ENT_TOKEN" ]; then
review_here "No app token for o2-enterprise, so no paired PR lookup"
fi
ent_repo=openobserve/o2-enterprise
# Any lookup error falls back to reviewing here: a missed pairing costs a duplicate review, a failed step costs the review.
if ! ent_json="$(GH_TOKEN="$ENT_TOKEN" gh api -X GET "repos/$ent_repo/pulls" -f state=open -f head="${ent_repo%%/*}:$BRANCH" \
--jq "[.[] | select(.head.repo.full_name == \"$ent_repo\")][0] // {} | {number, isDraft: .draft, labels}")"; then
review_here "Could not look up a paired $ent_repo PR"
fi
ent_pr="$(jq -r '.number // ""' <<<"$ent_json")"
if [ -z "$ent_pr" ]; then
echo "No open $ent_repo PR on branch '$BRANCH'; reviewing here."
echo "REVIEW_PAIR_CLEAR=true" >> "$GITHUB_ENV"
exit 0
fi
ent_ready="$(jq -r '(.isDraft | not) and ([.labels[].name] | index("ready-for-ci") != null)' <<<"$ent_json")"
if [ "$ent_ready" = "true" ]; then
bypass=()
if [ "$IGNORE_PASS_LIMIT" = "true" ]; then bypass=(-f ignore_pass_limit=true); fi
if [ "$FORCE_REVIEW" = "true" ]; then bypass+=(-f force_full=true); fi
if ! GH_TOKEN="$ENT_TOKEN" gh workflow run ai-code-review.yml --repo "$ent_repo" --ref main -f pr_number="$ent_pr" "${bypass[@]}"; then
echo "::warning::Could not dispatch the $ent_repo review; reviewing here."
exit 0
fi
state=reviewed
echo "::notice::Paired with $ent_repo#$ent_pr; dispatched its review instead of reviewing here."
else
state=pending
echo "::notice::Paired with $ent_repo#$ent_pr, which is a draft or lacks ready-for-ci; it reviews both PRs once it is ready."
fi
bash engine/scripts/ai-review/post-paired-pointer.sh "$REPO" "$PR_NUMBER" "$MARKER" "$ent_repo#$ent_pr" "$state" "$BOT_LOGIN" \
|| echo "::warning::Could not post the pointer comment."
echo "REVIEW_RUN=false" >> "$GITHUB_ENV"
- name: Check out PR head (data only)
if: env.REVIEW_RUN == 'true'
uses: actions/checkout@v5
with:
repository: ${{ github.repository }}
ref: ${{ steps.pr.outputs.head_sha }}
path: pr
fetch-depth: 1
persist-credentials: false
# The engine never touches the network with git; this one fetch makes the local-diff fallback possible.
- name: Fetch merge base for large diffs
if: env.REVIEW_RUN == 'true' && steps.pr.outputs.merge_base != ''
env:
GH_TOKEN: ${{ github.token }}
MERGE_BASE: ${{ steps.pr.outputs.merge_base }}
run: |
auth="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)"
git -C pr -c http.extraheader="AUTHORIZATION: basic $auth" fetch --no-tags --depth=1 origin "$MERGE_BASE" \
|| echo "::warning::Could not fetch merge base $MERGE_BASE; diffs over the API limit will fail."
# Sanitize deletes the PR's AGENTS.md/CLAUDE.md files, so the engine gets the rules of the engine's commit from outside ./pr.
# CLAUDE.md stands in wherever there is no AGENTS.md.
- name: Fetch repo rules (base branch)
if: env.REVIEW_RUN == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
rules="$RUNNER_TEMP/review-rules"
mkdir -p "$rules"
files="$GITHUB_WORKSPACE/engine/AGENTS.md"
if [ ! -f "$files" ] && [ -f "$GITHUB_WORKSPACE/engine/CLAUDE.md" ]; then
files="$GITHUB_WORKSPACE/engine/CLAUDE.md"
fi
fetched=""
for name in AGENTS.md CLAUDE.md; do
if gh api "repos/$REPO/contents/web/$name?ref=$GITHUB_SHA" -H "Accept: application/vnd.github.raw" > "$rules/web.$name.txt" 2>/dev/null; then
files="$files"$'\n'"$rules/web.$name.txt"
fetched=1
break
fi
done
if [ -z "$fetched" ]; then
echo "::warning::Could not fetch web/AGENTS.md or web/CLAUDE.md at $GITHUB_SHA; reviewing without the frontend rules."
fi
{
echo "REVIEW_RULES_FILES<<EOF_REVIEW_RULES"
echo "$files"
echo "EOF_REVIEW_RULES"
} >> "$GITHUB_ENV"
- name: Set up Node.js
if: env.REVIEW_RUN == 'true'
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: engine/scripts/ai-review/package-lock.json
- name: Install engine dependencies
if: env.REVIEW_RUN == 'true'
working-directory: engine/scripts/ai-review
run: npm ci --ignore-scripts
- name: Install opencode (pinned, checksum verified)
if: env.REVIEW_RUN == 'true'
run: |
bash engine/scripts/ai-review/install-opencode.sh "$RUNNER_TEMP/opencode"
echo "OPENCODE_BIN=$RUNNER_TEMP/opencode/opencode" >> "$GITHUB_ENV"
- name: Set trace ID
if: env.REVIEW_RUN == 'true'
run: echo "O2_TRACE_ID=$(openssl rand -hex 16)" >> "$GITHUB_ENV"
- name: Run AI Code Review
if: env.REVIEW_RUN == 'true'
env:
GH_TOKEN: ${{ steps.app_token.outputs.token || github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
MERGE_BASE_SHA: ${{ steps.pr.outputs.merge_base }}
ENGINE_DIR: ${{ github.workspace }}/engine
PR_DIR: ${{ github.workspace }}/pr
FORCE_FULL: ${{ inputs.force_full || 'false' }}
REVIEW_PROVIDER_ID: ${{ matrix.provider.provider_id }}
REVIEW_MODEL_ID: ${{ matrix.provider.model_id }}
# The effort is set here only, so the variable is the single source; each value must be a variant in opencode.ci.jsonc.
REVIEW_MODEL_VARIANT: ${{ vars.AI_REVIEW_EFFORT || 'high' }}
REVIEW_COORDINATOR_VARIANT: ${{ vars.AI_REVIEW_COORDINATOR_EFFORT || vars.AI_REVIEW_EFFORT || 'high' }}
REVIEW_API_KEY_ENV: ${{ matrix.provider.api_key_env }}
REVIEW_MARKER: ${{ matrix.provider.marker }}
# opencode.ci.jsonc: file tools plus a read-only shell allowlist, no web or sub-agent tools, no prompts; "default" is the read-only opencode.jsonc.
REVIEW_OPENCODE_CONFIG: ci-full
# Empty under the github.token fallback: any workflow a same-repo PR adds can also post as github-actions[bot].
REVIEW_BOT_LOGIN: ${{ steps.app_token.outputs.token && format('{0}[bot]', steps.app_token.outputs.app-slug) || '' }}
# A name no bundled opencode provider reads (they default to ANTHROPIC_API_KEY), so only anthropic-review gets the key.
ANTHROPIC_REVIEW_API_KEY: ${{ secrets.USAGE_CREDIT_API_KEY }}
O2_TRACE_INGEST_URL: ${{ secrets.O2_TRACE_INGEST_URL }}
O2_TRACE_ORG: ${{ secrets.O2_TRACE_ORG }}
O2_TRACE_USERNAME: ${{ secrets.O2_TRACE_USERNAME }}
O2_TRACE_PASSWORD: ${{ secrets.O2_TRACE_PASSWORD }}
O2_TRACE_AUTH: ${{ secrets.O2_TRACE_AUTH }}
O2_TRACE_STREAM: ${{ secrets.O2_TRACE_STREAM }}
O2_TRACE_EXPORT_TIMEOUT_MS: ${{ vars.O2_TRACE_EXPORT_TIMEOUT_MS }}
O2_TRACE_EXPORT_BATCH_SIZE: ${{ vars.O2_TRACE_EXPORT_BATCH_SIZE }}
# Set the variable to 'true' to also post inline threads; the summary comment always posts.
REVIEW_INLINE: ${{ vars.AI_REVIEW_INLINE || 'false' }}
# Optional ceiling on automatic reviews per PR; unset means no cap, so each new commit gets one review. The manual input bypasses it for one run.
REVIEW_MAX_PASSES: ${{ vars.AI_REVIEW_MAX_PASSES }}
REVIEW_IGNORE_PASS_LIMIT: ${{ inputs.ignore_pass_limit || 'false' }}
# A hand-started run reviews even a diff the last review already read.
REVIEW_MANUAL_RUN: ${{ github.event_name == 'workflow_dispatch' }}
# Independent reviewer sessions per area (1-3), merged by the coordinator; one cold pass misses findings a second catches.
REVIEW_COLD_PASSES: ${{ vars.AI_REVIEW_COLD_PASSES || '2' }}
# An unknown value means off, so a typo can never start approving.
REVIEW_AUTO_APPROVE: ${{ vars.AI_REVIEW_AUTO_APPROVE || 'shadow' }}
# Set only when the lookup proved no paired PR is open, so a failed or skipped lookup can never approve.
REVIEW_PAIR_CLEAR: ${{ env.REVIEW_PAIR_CLEAR }}
REVIEW_ARTIFACT_DIR: ${{ runner.temp }}/ai-review-findings
run: node engine/scripts/ai-review/run-review.mjs
# Parsed reviewer findings and the coordinator's kept list (model output only, scrubbed), for grading coordinator drops.
- name: Upload review findings
if: always() && env.REVIEW_RUN == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ai-review-findings-${{ matrix.provider.name }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ai-review-findings/
retention-days: 7
if-no-files-found: ignore