diff --git a/.github/workflows/on-pull-request.yml b/.github/workflows/on-pull-request.yml new file mode 100644 index 0000000000..7f01c51316 --- /dev/null +++ b/.github/workflows/on-pull-request.yml @@ -0,0 +1,104 @@ +name: "egg: Code Review" + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to review' + required: true + type: number + +jobs: + review: + name: AI Code Review + runs-on: ubuntu-latest + # Skip draft PRs, bot PRs, and PRs with [skip-review] in title + # For workflow_dispatch, always run (manual trigger implies intent) + if: >- + github.event_name == 'workflow_dispatch' || ( + !github.event.pull_request.draft && + github.event.pull_request.user.login != 'james-in-a-box' && + github.event.pull_request.user.login != 'james-in-a-box[bot]' && + !contains(github.event.pull_request.title, '[skip-review]') + ) + + permissions: + contents: read + pull-requests: write + + concurrency: + group: egg-review-${{ github.event.pull_request.number || github.event.inputs.pr_number }} + cancel-in-progress: true + + steps: + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + + # For workflow_dispatch, fetch PR metadata via API since + # github.event.pull_request is not available + - name: Fetch PR metadata + if: github.event_name == 'workflow_dispatch' + id: pr-meta + run: | + pr_json=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.inputs.pr_number }}) + echo "head-ref=$(echo "$pr_json" | jq -r '.head.ref')" >> "$GITHUB_OUTPUT" + echo "head-repo=$(echo "$pr_json" | jq -r '.head.repo.full_name')" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + + # SECURITY: Build the review prompt from a trusted checkout (main). + # A malicious PR could replace build-review-prompt.sh to exfiltrate + # the bot token. By checking out main for prompt building, we ensure + # untrusted code never runs with secrets. + - name: Checkout main (trusted) + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Build review prompt + id: prompt + run: bash action/build-review-prompt.sh + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || steps.pr-meta.outputs.head-repo }} + ref: ${{ github.event.pull_request.head.ref || steps.pr-meta.outputs.head-ref }} + persist-credentials: false + + - name: Capture actual HEAD SHA + id: head + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Run egg review + id: egg + uses: jwbron/egg/action@main + with: + prompt-file: ${{ steps.prompt.outputs.prompt-file }} + model: ${{ steps.prompt.outputs.model }} + anthropic-oauth-token: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} + bot-app-id: ${{ secrets.BOT_APP_ID }} + bot-app-private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + bot-app-installation-id: ${{ secrets.BOT_APP_INSTALLATION_ID }} + bot-username: james-in-a-box + timeout: "10" + + - name: Post review comments + if: always() && steps.egg.outputs.exit-code == '0' + run: bash action/post-review-comments.sh + env: + GH_TOKEN: ${{ steps.bot-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + HEAD_SHA: ${{ steps.head.outputs.sha }} + LOG_FILE: ${{ steps.egg.outputs.log-file }} + BOT_USERNAME: james-in-a-box diff --git a/action/action.yml b/action/action.yml index fcd95b2f40..9014b15096 100644 --- a/action/action.yml +++ b/action/action.yml @@ -6,8 +6,11 @@ branding: inputs: prompt: - description: Task prompt for Claude Code - required: true + description: Task prompt for Claude Code (mutually exclusive with prompt-file) + required: false + prompt-file: + description: Path to file containing task prompt (for large prompts, mutually exclusive with prompt) + required: false anthropic-oauth-token: description: Anthropic OAuth token for Claude API required: true @@ -64,6 +67,7 @@ runs: shell: bash env: INPUT_PROMPT: ${{ inputs.prompt }} + INPUT_PROMPT_FILE: ${{ inputs.prompt-file }} INPUT_ANTHROPIC_OAUTH_TOKEN: ${{ inputs.anthropic-oauth-token }} INPUT_GITHUB_TOKEN: ${{ inputs.github-token }} INPUT_BOT_APP_ID: ${{ inputs.bot-app-id }} diff --git a/action/build-review-prompt.sh b/action/build-review-prompt.sh new file mode 100755 index 0000000000..0b450b8dbd --- /dev/null +++ b/action/build-review-prompt.sh @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# build-review-prompt.sh — Build a review prompt for AI-powered code review +# +# Fetches PR details, diffs, and file contents, then assembles a structured +# prompt for Claude to review the code changes. +# +# Environment variables: +# PR_NUMBER — Pull request number to review +# GITHUB_REPOSITORY — owner/repo +# GH_TOKEN — GitHub token for API access +# RUNNER_TEMP — Temp directory for large prompt file +# +# Output: +# Sets 'prompt-file' and 'model' in $GITHUB_OUTPUT + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +MAX_DIFF_CHARS=15000 # Per-file diff limit +MAX_FILE_CHARS=30000 # Per-file content limit +MAX_PROMPT_CHARS=100000 # Overall prompt limit +MODEL_THRESHOLD_FILES=5 # Use opus for PRs with more than this many files + +# Files to skip (generated, binary, lock files) +SKIP_PATTERNS=( + '\.lock$' + '\.min\.js$' + '\.min\.css$' + 'package-lock\.json$' + 'yarn\.lock$' + 'pnpm-lock\.yaml$' + 'Pipfile\.lock$' + 'poetry\.lock$' + 'Gemfile\.lock$' + 'composer\.lock$' + 'go\.sum$' + 'Cargo\.lock$' + '\.pyc$' + '\.pyo$' + '__pycache__' + '\.class$' + '\.jar$' + '\.war$' + '\.so$' + '\.dylib$' + '\.dll$' + '\.exe$' + '\.bin$' + '\.png$' + '\.jpg$' + '\.jpeg$' + '\.gif$' + '\.ico$' + '\.svg$' + '\.woff' + '\.ttf$' + '\.eot$' + '\.pdf$' + '\.zip$' + '\.tar' + '\.gz$' +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +truncate_text() { + local text="$1" + local max_chars="$2" + if [[ ${#text} -gt $max_chars ]]; then + echo "${text:0:$max_chars} + +... (truncated, ${#text} total chars)" + else + echo "$text" + fi +} + +should_skip_file() { + local filename="$1" + for pattern in "${SKIP_PATTERNS[@]}"; do + if [[ "$filename" =~ $pattern ]]; then + return 0 + fi + done + return 1 +} + +# Safe gh api wrapper (with proper quoting in error message) +gh_api_safe() { + local stderr_file + stderr_file=$(mktemp) + local output + # Capture the command for error reporting (properly quoted) + local cmd_display + cmd_display=$(printf "'gh api %s'" "$*") + + if output=$(gh api "$@" 2>"$stderr_file"); then + rm -f "$stderr_file" + echo "$output" + else + local rc=$? + local stderr_content + stderr_content=$(cat "$stderr_file") + rm -f "$stderr_file" + echo "WARNING: ${cmd_display} failed (exit $rc): ${stderr_content}" >&2 + return 0 + fi +} + +# --------------------------------------------------------------------------- +# Fetch PR data +# --------------------------------------------------------------------------- + +fetch_pr_details() { + gh_api_safe "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \ + --jq '{ + title: .title, + body: .body, + state: .state, + base: .base.ref, + head: .head.ref, + head_sha: .head.sha, + user: .user.login, + html_url: .html_url + }' +} + +fetch_pr_files() { + # Returns JSON array of changed files with patches + gh_api_safe "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '[.[] | { + filename: .filename, + status: .status, + additions: .additions, + deletions: .deletions, + patch: .patch + }]' +} + +fetch_file_content() { + local filename="$1" + local ref="$2" + # Fetch raw file content from the head commit + local b64_content + b64_content=$(gh_api_safe "repos/${GITHUB_REPOSITORY}/contents/${filename}?ref=${ref}" \ + --jq '.content // empty' 2>/dev/null) + + if [[ -z "$b64_content" ]]; then + echo "WARNING: No content returned for ${filename}" >&2 + echo "" + return + fi + + # Attempt base64 decode with error handling + local decoded + if decoded=$(echo "$b64_content" | base64 -d 2>/dev/null); then + echo "$decoded" + else + echo "WARNING: Failed to base64 decode ${filename}" >&2 + echo "" + fi +} + +fetch_review_rules() { + # Try to fetch .egg/review-rules.md from the repo + local b64_content + b64_content=$(gh_api_safe "repos/${GITHUB_REPOSITORY}/contents/.egg/review-rules.md?ref=main" \ + --jq '.content // empty' 2>/dev/null) + + local content="" + if [[ -n "$b64_content" ]]; then + content=$(echo "$b64_content" | base64 -d 2>/dev/null) || content="" + fi + + if [[ -z "$content" ]]; then + # Default review rules + cat <<'EOF' +## Default Review Rules + +Focus on: +- Security issues (vulnerabilities, unsafe patterns, credential leaks) +- Correctness (logic errors, edge cases, error handling gaps) +- Code quality (readability, maintainability, naming) + +Skip: +- Style issues handled by linters (formatting, import order) +- Type annotation completeness (type checkers handle this) +- Auto-generated files (migrations, lock files) +EOF + else + echo "$content" + fi +} + +# --------------------------------------------------------------------------- +# Build the prompt +# --------------------------------------------------------------------------- + +build_prompt() { + local pr_details + pr_details=$(fetch_pr_details) + + local title body base head head_sha user html_url + title=$(echo "$pr_details" | jq -r '.title // "Untitled"') + body=$(echo "$pr_details" | jq -r '.body // ""') + base=$(echo "$pr_details" | jq -r '.base // "main"') + head=$(echo "$pr_details" | jq -r '.head // "unknown"') + head_sha=$(echo "$pr_details" | jq -r '.head_sha // ""') + user=$(echo "$pr_details" | jq -r '.user // "unknown"') + html_url=$(echo "$pr_details" | jq -r '.html_url // ""') + + # Fetch changed files + local files_json + files_json=$(fetch_pr_files) + + local file_count + file_count=$(echo "$files_json" | jq 'length') + + # Determine model based on file count + local model="haiku" + if [[ "$file_count" -gt "$MODEL_THRESHOLD_FILES" ]]; then + model="opus" + fi + + # Fetch review rules + local review_rules + review_rules=$(fetch_review_rules) + + # Build changed files section + local changed_files_section="" + local file_contents_section="" + local skipped_files="" + + while IFS= read -r file_entry; do + local filename status additions deletions patch + filename=$(echo "$file_entry" | jq -r '.filename') + status=$(echo "$file_entry" | jq -r '.status') + additions=$(echo "$file_entry" | jq -r '.additions') + deletions=$(echo "$file_entry" | jq -r '.deletions') + patch=$(echo "$file_entry" | jq -r '.patch // ""') + + # Skip binary/generated files + if should_skip_file "$filename"; then + skipped_files="${skipped_files}${filename} (skipped: generated/binary)\n" + continue + fi + + # Add to changed files section + changed_files_section="${changed_files_section} +### ${filename} +Status: ${status} (+${additions}/-${deletions}) + +\`\`\`diff +$(truncate_text "$patch" "$MAX_DIFF_CHARS") +\`\`\` +" + + # Fetch full file content for modified/added files + if [[ "$status" != "removed" ]]; then + local content + content=$(fetch_file_content "$filename" "$head_sha") + if [[ -n "$content" ]]; then + file_contents_section="${file_contents_section} +### ${filename} +\`\`\` +$(truncate_text "$content" "$MAX_FILE_CHARS") +\`\`\` +" + fi + fi + done < <(echo "$files_json" | jq -c '.[]') + + # Assemble the full prompt + local prompt + prompt="You are reviewing PR #${PR_NUMBER}: \"${title}\" in ${GITHUB_REPOSITORY}. + +Author: ${user} +Branch: ${head} -> ${base} +URL: ${html_url} + +## PR Description + +${body:-No description provided.} + +## Review Rules + +${review_rules} + +## Changed Files (${file_count} files) +${changed_files_section} +" + + # Add file contents section if we have any + if [[ -n "$file_contents_section" ]]; then + prompt="${prompt} +## Full File Context +${file_contents_section} +" + fi + + # Add skipped files note + if [[ -n "$skipped_files" ]]; then + prompt="${prompt} +## Skipped Files +$(echo -e "$skipped_files") +" + fi + + # Add review instructions + prompt="${prompt} +## Instructions + +Review this PR for: +1. **Security issues** — vulnerabilities, unsafe patterns, credential leaks, injection risks +2. **Correctness** — logic errors, edge cases, error handling gaps, race conditions +3. **Code quality** — readability, maintainability, naming, unnecessary complexity +4. **Standards compliance** — project conventions per review rules above + +For each issue found, output a structured JSON block: +\`\`\`json +{ + \"file\": \"path/to/file\", + \"line\": , + \"severity\": \"critical|warning|suggestion\", + \"category\": \"security|correctness|quality|standards\", + \"comment\": \"Description of the issue and suggested fix\" +} +\`\`\` + +The \"line\" field must be the actual line number in the file (as shown in the GitHub file viewer on the HEAD commit). The posting script will automatically convert this to the correct diff position for inline comments. If a line number is not in the diff (e.g., for context lines), the comment will be included in the review body instead. + +At the end, provide a summary in this format: +\`\`\`json +{ + \"summary\": \"Overall assessment of the PR\", + \"verdict\": \"approve|request_changes|comment\", + \"comments\": [] +} +\`\`\` + +Rules for comments: +- Only comment on things that are actually wrong or risky +- Do not comment on style preferences already handled by linters (ruff, eslint, prettier) +- Do not repeat what ruff, mypy, shellcheck, or bandit would catch +- Focus on issues that require human judgment to detect +- Be specific: reference the exact line and explain why it's a problem +- Suggest a fix when possible +- Bias toward fewer, higher-signal comments — a noisy reviewer gets ignored + +If the PR looks good with no significant issues, output: +\`\`\`json +{\"summary\": \"No significant issues found. The changes look good.\", \"verdict\": \"approve\", \"comments\": []} +\`\`\` +" + + # Truncate overall prompt if needed + prompt=$(truncate_text "$prompt" "$MAX_PROMPT_CHARS") + + # Write prompt to temp file (avoids GITHUB_OUTPUT size limits) + local prompt_file="${RUNNER_TEMP:-/tmp}/review-prompt-${PR_NUMBER}.txt" + echo "$prompt" > "$prompt_file" + + # Write outputs + { + echo "prompt-file=${prompt_file}" + echo "model=${model}" + } >> "${GITHUB_OUTPUT:-/dev/null}" + + echo "Review prompt built: ${#prompt} chars, ${file_count} files, model=${model}" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +build_prompt diff --git a/action/entrypoint.sh b/action/entrypoint.sh index 33b134d33b..02a9bcb79f 100755 --- a/action/entrypoint.sh +++ b/action/entrypoint.sh @@ -68,6 +68,20 @@ export INPUT_BOT_USERNAME="${INPUT_BOT_USERNAME:-egg}" CONFIG_DIR="${RUNNER_TEMP:-/tmp}/egg-config-${RUN_ID}" +# --------------------------------------------------------------------------- +# Step 2b: Resolve prompt from file if needed +# --------------------------------------------------------------------------- + +if [[ -n "${INPUT_PROMPT_FILE:-}" && -f "${INPUT_PROMPT_FILE}" ]]; then + echo "Reading prompt from file: ${INPUT_PROMPT_FILE}" + export INPUT_PROMPT + INPUT_PROMPT=$(cat "${INPUT_PROMPT_FILE}") + echo "Prompt loaded: ${#INPUT_PROMPT} chars" +elif [[ -z "${INPUT_PROMPT:-}" ]]; then + echo "ERROR: Either prompt or prompt-file input is required" >&2 + exit 1 +fi + # --------------------------------------------------------------------------- # Step 3: Run Python orchestration # --------------------------------------------------------------------------- diff --git a/action/post-review-comments.sh b/action/post-review-comments.sh new file mode 100755 index 0000000000..025588c770 --- /dev/null +++ b/action/post-review-comments.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# post-review-comments.sh — Parse Claude's review output and post GitHub review comments +# +# Reads the Claude output log, extracts JSON review comments, and posts them +# as a GitHub pull request review with inline comments. +# +# Environment variables: +# PR_NUMBER — Pull request number +# GITHUB_REPOSITORY — owner/repo +# GH_TOKEN — GitHub token for API access +# LOG_FILE — Path to Claude's output log +# HEAD_SHA — Commit SHA to attach comments to +# DIFF_FILE — (optional) Path to PR diff for line number mapping +# +# The script expects Claude's output to contain a JSON summary block like: +# { +# "summary": "Overall assessment", +# "verdict": "approve|request_changes|comment", +# "comments": [ +# {"file": "path", "line": N, "severity": "...", "category": "...", "comment": "..."} +# ] +# } + +set -euo pipefail + +BOT_USERNAME="${BOT_USERNAME:-james-in-a-box}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Fetch PR diff patches for line number mapping +# Returns JSON: {"file.py": "patch content", ...} +fetch_pr_patches() { + gh_api_safe "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '[.[] | {key: .filename, value: .patch}] | from_entries' 2>/dev/null || echo "{}" +} + +# Convert absolute line number to diff position for GitHub review API +# GitHub expects a "position" in the diff, not the absolute line number +# Returns the position or empty string if line not found in diff +# +# Args: $1 = patch content, $2 = absolute line number +get_diff_position() { + local patch="$1" + local target_line="$2" + + if [[ -z "$patch" ]] || [[ -z "$target_line" ]]; then + echo "" + return + fi + + # Parse the diff to find the position + # Position is 1-indexed, counting all lines in the diff (including hunk headers) + python3 -c " +import sys + +patch = '''$patch''' +target_line = int('$target_line') + +position = 0 +current_new_line = 0 + +for line in patch.split('\n'): + position += 1 + + # Parse hunk header: @@ -old_start,old_count +new_start,new_count @@ + if line.startswith('@@'): + import re + match = re.search(r'\+(\d+)', line) + if match: + current_new_line = int(match.group(1)) - 1 # -1 because we increment before checking + continue + + # Skip removed lines (they don't exist in new file) + if line.startswith('-'): + continue + + # Context or added line + current_new_line += 1 + + if current_new_line == target_line: + print(position) + sys.exit(0) + +# Line not found in diff +sys.exit(1) +" 2>/dev/null || echo "" +} + +# Safe gh api wrapper (with proper quoting in error message) +gh_api_safe() { + local stderr_file + stderr_file=$(mktemp) + local output + # Capture the command for error reporting (properly quoted) + local cmd_display + cmd_display=$(printf "'gh api %s'" "$*") + + if output=$(gh api "$@" 2>"$stderr_file"); then + rm -f "$stderr_file" + echo "$output" + else + local rc=$? + local stderr_content + stderr_content=$(cat "$stderr_file") + rm -f "$stderr_file" + echo "WARNING: ${cmd_display} failed (exit $rc): ${stderr_content}" >&2 + return 1 + fi +} + +# Extract JSON from Claude's output +# Claude may wrap JSON in markdown code fences or include preamble/postamble +# Uses Python for robust JSON parsing that handles nested objects correctly +extract_review_json() { + local log_content="$1" + + # Use Python for robust JSON extraction that handles nested braces correctly + local json_block + json_block=$(echo "$log_content" | python3 -c " +import sys +import re +import json + +content = sys.stdin.read() + +def find_json_objects(text): + '''Find all valid JSON objects in text using bracket matching.''' + objects = [] + i = 0 + while i < len(text): + if text[i] == '{': + # Found potential JSON start, find matching close brace + depth = 1 + j = i + 1 + in_string = False + escape_next = False + + while j < len(text) and depth > 0: + c = text[j] + + if escape_next: + escape_next = False + elif c == '\\\\' and in_string: + escape_next = True + elif c == '\"' and not escape_next: + in_string = not in_string + elif not in_string: + if c == '{': + depth += 1 + elif c == '}': + depth -= 1 + j += 1 + + if depth == 0: + candidate = text[i:j] + try: + obj = json.loads(candidate) + if isinstance(obj, dict): + objects.append(obj) + except json.JSONDecodeError: + pass + i = j + else: + i += 1 + else: + i += 1 + return objects + +# First, try to extract from markdown code blocks +code_block_pattern = r'\`\`\`(?:json)?\s*\n(.*?)\n\`\`\`' +code_blocks = re.findall(code_block_pattern, content, re.DOTALL) + +# Check code blocks first (in reverse order to get the final summary) +for block in reversed(code_blocks): + for obj in find_json_objects(block): + if 'summary' in obj and 'comments' in obj: + print(json.dumps(obj)) + sys.exit(0) + +# Then check the full content for bare JSON +all_objects = find_json_objects(content) + +# Look for the summary object (should have summary, verdict, comments) +for obj in reversed(all_objects): + if 'summary' in obj and 'comments' in obj: + print(json.dumps(obj)) + sys.exit(0) + +# Fallback: try to find any object with comments array +for obj in reversed(all_objects): + if 'comments' in obj and isinstance(obj.get('comments'), list): + # Add missing fields + obj.setdefault('summary', 'Review completed.') + obj.setdefault('verdict', 'comment') + print(json.dumps(obj)) + sys.exit(0) + +# No structured output found +print(json.dumps({ + 'summary': 'Review completed but no structured output found.', + 'verdict': 'comment', + 'comments': [] +})) +" 2>/dev/null) + + if [[ -n "$json_block" ]]; then + echo "$json_block" + return 0 + fi + + # Fallback: no structured review found + echo '{"summary": "Review completed but no structured output found.", "verdict": "comment", "comments": []}' +} + +# Dismiss previous reviews from the bot to avoid clutter +dismiss_previous_reviews() { + echo "Checking for previous bot reviews to dismiss..." + + # Get all reviews on this PR + local reviews + reviews=$(gh_api_safe "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + --jq "[.[] | select(.user.login == \"${BOT_USERNAME}\" or .user.login == \"${BOT_USERNAME}[bot]\") | {id: .id, state: .state}]" 2>/dev/null || echo "[]") + + if [[ "$reviews" == "[]" ]] || [[ -z "$reviews" ]]; then + echo "No previous bot reviews found." + return 0 + fi + + # Collect all review IDs to dismiss first (avoids subshell issues with while read) + local review_ids=() + local review_states=() + + while IFS= read -r review; do + local review_id state + review_id=$(echo "$review" | jq -r '.id') + state=$(echo "$review" | jq -r '.state') + + # Only dismiss PENDING, COMMENTED, CHANGES_REQUESTED reviews + if [[ "$state" =~ ^(PENDING|COMMENTED|CHANGES_REQUESTED)$ ]]; then + review_ids+=("$review_id") + review_states+=("$state") + fi + done < <(echo "$reviews" | jq -c '.[]' 2>/dev/null) + + local dismiss_count=${#review_ids[@]} + if [[ "$dismiss_count" -eq 0 ]]; then + echo "No dismissible reviews found." + return 0 + fi + + echo "Found ${dismiss_count} reviews to dismiss." + + # Now dismiss each review + local failed=0 + for i in "${!review_ids[@]}"; do + local review_id="${review_ids[$i]}" + local state="${review_states[$i]}" + + echo "Dismissing review ${review_id} (state: ${state})..." + if ! gh_api_safe "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${review_id}/dismissals" \ + -X PUT \ + -f message="Superseded by new review" \ + >/dev/null 2>&1; then + echo "WARNING: Could not dismiss review ${review_id}" + ((failed++)) || true + fi + done + + if [[ "$failed" -gt 0 ]]; then + echo "WARNING: Failed to dismiss ${failed} of ${dismiss_count} reviews" + fi +} + +# Post the review +post_review() { + local summary="$1" + local verdict="$2" + local comments_json="$3" + + # Map verdict to GitHub review event + # We use COMMENT for all verdicts to keep reviews advisory-only + # (not blocking merges). The verdict is included in the summary text. + local event="COMMENT" + + # Build the review body + local severity_counts + severity_counts=$(echo "$comments_json" | jq -r ' + group_by(.severity) | + map("\(.[0].severity): \(length)") | + join(", ") + ' 2>/dev/null || echo "") + + local body="## AI Code Review + +${summary} + +" + if [[ -n "$severity_counts" ]]; then + body="${body}**Issues found:** ${severity_counts} + +" + fi + + body="${body}--- +*This is an automated review. Please evaluate suggestions carefully.* + +— Authored by egg" + + # Fetch PR patches for line number mapping + echo "Fetching PR patches for line number mapping..." + local patches_json + patches_json=$(fetch_pr_patches) + + # Build comments array for GitHub API with proper diff position mapping + # GitHub expects: [{path, position, side, body}, ...] + # Note: 'position' is diff-relative, not 'line' (absolute) + local gh_comments="[]" + local skipped_comments="[]" + + while IFS= read -r comment; do + local file line severity category comment_text + file=$(echo "$comment" | jq -r '.file // ""') + line=$(echo "$comment" | jq -r '.line // 0') + severity=$(echo "$comment" | jq -r '.severity // "comment"') + category=$(echo "$comment" | jq -r '.category // "general"') + comment_text=$(echo "$comment" | jq -r '.comment // ""') + + # Skip invalid comments + if [[ -z "$file" ]] || [[ "$line" -le 0 ]]; then + continue + fi + + # Get the patch for this file + local patch + patch=$(echo "$patches_json" | jq -r --arg f "$file" '.[$f] // ""') + + # Convert absolute line number to diff position + local position + position=$(get_diff_position "$patch" "$line") + + local comment_body="**${severity}** (${category}): ${comment_text}" + + if [[ -n "$position" ]]; then + # Valid diff position found + gh_comments=$(echo "$gh_comments" | jq -c \ + --arg path "$file" \ + --argjson position "$position" \ + --arg body "$comment_body" \ + '. + [{path: $path, position: $position, side: "RIGHT", body: $body}]') + else + # Line not in diff - add to skipped for fallback display + skipped_comments=$(echo "$skipped_comments" | jq -c \ + --arg file "$file" \ + --argjson line "$line" \ + --arg severity "$severity" \ + --arg category "$category" \ + --arg comment "$comment_text" \ + '. + [{file: $file, line: $line, severity: $severity, category: $category, comment: $comment}]') + echo "WARNING: Line $line in $file not found in diff, will include in body" + fi + done < <(echo "$comments_json" | jq -c '.[]' 2>/dev/null) + + # Add skipped comments to body if any + local skipped_count + skipped_count=$(echo "$skipped_comments" | jq 'length') + if [[ "$skipped_count" -gt 0 ]]; then + local skipped_text + skipped_text=$(echo "$skipped_comments" | jq -r ' + .[] | "- **\(.file):\(.line)** [\(.severity)/\(.category)] \(.comment)" + ') + body="${body} + +### Additional comments (lines not in diff) + +${skipped_text}" + fi + + local comment_count + comment_count=$(echo "$gh_comments" | jq 'length') + + echo "Posting review with ${comment_count} inline comments..." + + # Build the review payload + local payload + if [[ "$comment_count" -gt 0 ]]; then + payload=$(jq -n \ + --arg commit_id "$HEAD_SHA" \ + --arg event "$event" \ + --arg body "$body" \ + --argjson comments "$gh_comments" \ + '{commit_id: $commit_id, event: $event, body: $body, comments: $comments}') + else + payload=$(jq -n \ + --arg commit_id "$HEAD_SHA" \ + --arg event "$event" \ + --arg body "$body" \ + '{commit_id: $commit_id, event: $event, body: $body}') + fi + + # Post the review + if echo "$payload" | gh_api_safe "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + -X POST \ + --input - >/dev/null; then + echo "Review posted successfully!" + else + echo "Failed to post review with inline comments. Posting as regular comment..." + # Pass all original comments to fallback (including skipped ones) + post_fallback_comment "$body" "$comments_json" + fi +} + +# Fallback: post as a regular PR comment if review API fails +post_fallback_comment() { + local body="$1" + local comments_json="$2" + + # Include inline comments in the body since we can't post them inline + local comment_count + comment_count=$(echo "$comments_json" | jq 'length') + + if [[ "$comment_count" -gt 0 ]]; then + local comments_text + comments_text=$(echo "$comments_json" | jq -r ' + .[] | "- **\(.file):\(.line)** [\(.severity)/\(.category)] \(.comment)" + ') + body="${body} + +### Inline Comments + +${comments_text}" + fi + + echo "Posting fallback comment..." + gh_api_safe "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -X POST \ + -f body="$body" >/dev/null + + echo "Fallback comment posted." +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${LOG_FILE:?LOG_FILE is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" + +if [[ ! -f "$LOG_FILE" ]]; then + echo "ERROR: Log file not found: $LOG_FILE" + exit 1 +fi + +echo "Parsing Claude's review output from: $LOG_FILE" + +# Read the log file +log_content=$(cat "$LOG_FILE") + +# Extract the review JSON +review_json=$(extract_review_json "$log_content") + +echo "Extracted review JSON:" +echo "$review_json" | jq -C . 2>/dev/null || echo "$review_json" + +# Parse the review +summary=$(echo "$review_json" | jq -r '.summary // "Review completed."') +verdict=$(echo "$review_json" | jq -r '.verdict // "comment"') +comments=$(echo "$review_json" | jq -c '.comments // []') + +echo "" +echo "Summary: $summary" +echo "Verdict: $verdict" +echo "Comments: $(echo "$comments" | jq 'length')" + +# Dismiss previous bot reviews +dismiss_previous_reviews + +# Post the new review +post_review "$summary" "$verdict" "$comments" + +echo "Done!"