name: PR Review concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.prNumber }} cancel-in-progress: false on: pull_request_target: types: [opened, synchronize, ready_for_review] issue_comment: types: [created] workflow_dispatch: inputs: prNumber: description: 'The number of the PR to review manually' required: true type: string jobs: review-pr: # Bot check is in the issue_comment branch - workflow shows "skipped" for bot comments if: | github.event_name == 'workflow_dispatch' || (github.event.action == 'opened' && github.event.pull_request.draft == false) || github.event.action == 'ready_for_review' || (github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'Agent Monitored')) || ( github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.user.login != 'mirrobot' && github.event.comment.user.login != 'mirrobot-agent' && github.event.comment.user.login != 'mirrobot-agent[bot]' && (contains(github.event.comment.body, '/mirrobot-review') || contains(github.event.comment.body, '/mirrobot_review')) ) runs-on: ubuntu-latest permissions: contents: read pull-requests: write env: PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number || inputs.prNumber }} BOT_NAMES_JSON: '["mirrobot", "mirrobot-agent", "mirrobot-agent[bot]"]' IGNORE_BOT_NAMES_JSON: '["ellipsis-dev"]' COMMENT_FETCH_LIMIT: '20' REVIEW_FETCH_LIMIT: '30' REVIEW_THREAD_FETCH_LIMIT: '40' THREAD_COMMENT_FETCH_LIMIT: '5' steps: # ======================================================================== # COMMENT VALIDATION STEP (only for issue_comment events) # ======================================================================== # Validates that trigger words are in actual content (not in quotes/code) # If validation fails, subsequent steps are skipped # ======================================================================== - name: Validate comment trigger id: validate if: github.event_name == 'issue_comment' env: COMMENT_BODY: ${{ github.event.comment.body }} run: | set -e # Save comment to temp file for processing TEMP_FILE=$(mktemp) echo "$COMMENT_BODY" > "$TEMP_FILE" # Remove fenced code blocks (```...```) CLEAN_BODY=$(awk ' /^```/ { in_code = !in_code; next } !in_code { print } ' "$TEMP_FILE") # Remove inline code (`...`) CLEAN_BODY=$(echo "$CLEAN_BODY" | sed 's/`[^`]*`//g') # Remove quoted lines (lines starting with >) CLEAN_BODY=$(echo "$CLEAN_BODY" | grep -v '^[[:space:]]*>' || true) rm -f "$TEMP_FILE" echo "Clean body after stripping quotes/code:" echo "$CLEAN_BODY" echo "---" # Check for trigger words in clean text # Trigger: /mirrobot-review or /mirrobot_review if echo "$CLEAN_BODY" | grep -qE '/mirrobot[-_]review'; then echo "::notice::Valid trigger found in non-quoted, non-code text." echo "should_proceed=true" >> $GITHUB_OUTPUT else echo "::notice::Trigger only found in quotes/code blocks. Skipping." echo "should_proceed=false" >> $GITHUB_OUTPUT fi - name: Checkout repository if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' uses: actions/checkout@v4 - name: Bot Setup if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' id: setup uses: ./.github/actions/bot-setup with: bot-app-id: ${{ secrets.BOT_APP_ID }} bot-private-key: ${{ secrets.BOT_PRIVATE_KEY }} opencode-api-key: ${{ secrets.OPENCODE_API_KEY }} opencode-model: ${{ secrets.OPENCODE_MODEL }} opencode-fast-model: ${{ secrets.OPENCODE_FAST_MODEL }} opencode-config-json: ${{ secrets.OPENCODE_CONFIG_JSON }} - name: Clear pending bot review if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' env: GH_TOKEN: ${{ steps.setup.outputs.token }} BOT_NAMES_JSON: ${{ env.BOT_NAMES_JSON }} run: | pending_review_ids=$(gh api --paginate \ "/repos/${GITHUB_REPOSITORY}/pulls/${{ env.PR_NUMBER }}/reviews" \ | jq -r --argjson bots "$BOT_NAMES_JSON" '.[]? | select((.state // "") == "PENDING" and (((.user.login // "") as $login | $bots | index($login)))) | .id' \ | sort -u) if [ -z "$pending_review_ids" ]; then echo "No pending bot reviews to clear." exit 0 fi while IFS= read -r review_id; do [ -z "$review_id" ] && continue if gh api \ --method DELETE \ -H "Accept: application/vnd.github+json" \ "/repos/${GITHUB_REPOSITORY}/pulls/${{ env.PR_NUMBER }}/reviews/$review_id"; then echo "Cleared pending review $review_id" else echo "::warning::Failed to clear pending review $review_id" fi done <<< "$pending_review_ids" - name: Add reaction to PR if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' env: GH_TOKEN: ${{ steps.setup.outputs.token }} BOT_NAMES_JSON: ${{ env.BOT_NAMES_JSON }} IGNORE_BOT_NAMES_JSON: ${{ env.IGNORE_BOT_NAMES_JSON }} run: | gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ /repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/reactions \ -f content='eyes' - name: Fetch and Format Full PR Context if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' id: pr_meta env: GH_TOKEN: ${{ steps.setup.outputs.token }} run: | # Fetch core PR metadata (comments and reviews fetched via GraphQL below) pr_json=$(gh pr view ${{ env.PR_NUMBER }} --repo ${{ github.repository }} --json author,title,body,createdAt,state,headRefName,baseRefName,headRefOid,additions,deletions,commits,files,closingIssuesReferences,headRepository) # Fetch timeline data to find cross-references timeline_data=$(gh api "/repos/${{ github.repository }}/issues/${{ env.PR_NUMBER }}/timeline") repo_owner="${GITHUB_REPOSITORY%/*}" repo_name="${GITHUB_REPOSITORY#*/}" GRAPHQL_QUERY='query($owner:String!, $name:String!, $number:Int!, $commentLimit:Int!, $reviewLimit:Int!, $threadLimit:Int!, $threadCommentLimit:Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { comments(last: $commentLimit) { nodes { databaseId author { login } body createdAt isMinimized minimizedReason } } reviews(last: $reviewLimit) { nodes { databaseId author { login } body state submittedAt isMinimized minimizedReason } } reviewThreads(last: $threadLimit) { nodes { id isResolved isOutdated comments(last: $threadCommentLimit) { nodes { databaseId author { login } body createdAt path line originalLine diffHunk isMinimized minimizedReason pullRequestReview { databaseId isMinimized minimizedReason } } } } } } } }' discussion_data=$(gh api graphql \ -F owner="$repo_owner" \ -F name="$repo_name" \ -F number=${{ env.PR_NUMBER }} \ -F commentLimit=${{ env.COMMENT_FETCH_LIMIT }} \ -F reviewLimit=${{ env.REVIEW_FETCH_LIMIT }} \ -F threadLimit=${{ env.REVIEW_THREAD_FETCH_LIMIT }} \ -F threadCommentLimit=${{ env.THREAD_COMMENT_FETCH_LIMIT }} \ -f query="$GRAPHQL_QUERY") # Debug: Output pr_json and the discussion GraphQL payload for inspection echo "$pr_json" > pr_json.txt echo "$discussion_data" > discussion_data.txt # Prepare metadata author=$(echo "$pr_json" | jq -r .author.login) created_at=$(echo "$pr_json" | jq -r .createdAt) base_branch=$(echo "$pr_json" | jq -r .baseRefName) head_branch=$(echo "$pr_json" | jq -r .headRefName) state=$(echo "$pr_json" | jq -r .state) additions=$(echo "$pr_json" | jq -r .additions) deletions=$(echo "$pr_json" | jq -r .deletions) total_commits=$(echo "$pr_json" | jq -r '.commits | length') changed_files_count=$(echo "$pr_json" | jq -r '.files | length') title=$(echo "$pr_json" | jq -r .title) body=$(echo "$pr_json" | jq -r '.body // "(No description provided)"') # Build changed files list with correct jq interpolations for additions and deletions # Previous pattern had a missing backslash before the deletions interpolation, leaving a literal '((.deletions))'. changed_files_list=$(echo "$pr_json" | jq -r '.files[] | "- \(.path) (MODIFIED) +\((.additions))/-\((.deletions))"') comments=$(echo "$discussion_data" | jq -r --argjson ignored "$IGNORE_BOT_NAMES_JSON" ' ((.data.repository.pullRequest.comments.nodes // []) | map(select((.isMinimized != true) and (((.author.login? // "unknown") as $login | $ignored | index($login)) | not)))) | if length > 0 then map("- " + (.author.login? // "unknown") + " at " + (.createdAt // "N/A") + ":\n" + ((.body // "") | tostring) + "\n") | join("") else "No general comments." end') # ===== ACCURATE FILTERING & COUNTING (Fixed math logic) ===== # Calculate all stats using jq integers directly to avoid grep/text parsing errors stats_json=$(echo "$discussion_data" | jq -r --argjson ignored "$IGNORE_BOT_NAMES_JSON" ' # Define filter logic def is_valid_review: (.author.login? // "unknown") as $login | $ignored | index($login) | not and (.isMinimized != true); def is_valid_comment: .isResolved != true and .isOutdated != true and (((.comments.nodes // []) | first | .isMinimized) != true) and ((((.comments.nodes // []) | first | .pullRequestReview.isMinimized) // false) != true); def is_valid_inline: .isMinimized != true and ((.pullRequestReview.isMinimized // false) != true) and (((.author.login? // "unknown") as $login | $ignored | index($login)) | not); # Calculate Reviews def raw_reviews: (.data.repository.pullRequest.reviews.nodes // []); def total_reviews: (raw_reviews | length); def included_reviews: ([raw_reviews[]? | select(is_valid_review)] | length); # Calculate Review Comments def raw_threads: (.data.repository.pullRequest.reviewThreads.nodes // []); def valid_threads: (raw_threads | map(select(is_valid_comment))); def all_valid_comments: (valid_threads | map(.comments.nodes // []) | flatten | map(select(is_valid_inline))); # We count total comments as "active/unresolved threads comments" def total_review_comments: (raw_threads | map(select(.isResolved != true and .isOutdated != true)) | map(.comments.nodes // []) | flatten | length); def included_review_comments: (all_valid_comments | length); { total_reviews: total_reviews, included_reviews: included_reviews, excluded_reviews: (total_reviews - included_reviews), total_review_comments: total_review_comments, included_review_comments: included_review_comments, excluded_comments: (total_review_comments - included_review_comments) } ') # Export stats to env vars filtered_reviews=$(echo "$stats_json" | jq .included_reviews) excluded_reviews=$(echo "$stats_json" | jq .excluded_reviews) filtered_comments=$(echo "$stats_json" | jq .included_review_comments) excluded_comments=$(echo "$stats_json" | jq .excluded_comments) echo "✓ Filtered reviews: $filtered_reviews included, $excluded_reviews excluded (ignored bots/hidden)" echo "✓ Filtered review comments: $filtered_comments included, $excluded_comments excluded (outdated/hidden)" # Generate Text Content (using same filters as stats) # Reviews Text review_filter_err=$(mktemp 2>/dev/null || echo "/tmp/review_filter_err.log") if reviews=$(echo "$discussion_data" | jq -r --argjson ignored "$IGNORE_BOT_NAMES_JSON" ' if ((((.data.repository.pullRequest.reviews.nodes // []) | length) > 0)) then ((.data.repository.pullRequest.reviews.nodes // [])[]? | select( ((.author.login? // "unknown") as $login | $ignored | index($login) | not) and (.isMinimized != true) ) | "- " + (.author.login? // "unknown") + " at " + (.submittedAt // "N/A") + ":\n - Review body: " + (.body // "(No summary comment)") + "\n - State: " + (.state // "UNKNOWN") + "\n") else "No formal reviews." end' 2>"$review_filter_err"); then if [ -s "$review_filter_err" ]; then echo "::debug::jq stderr (reviews) emitted output:" cat "$review_filter_err" fi else echo "::warning::Review formatting failed, using unfiltered data" reviews="Error processing reviews." echo "FILTER_ERROR_REVIEWS=true" >> $GITHUB_ENV fi rm -f "$review_filter_err" || true # Review Comments Text review_comment_filter_err=$(mktemp 2>/dev/null || echo "/tmp/review_comment_filter_err.log") if review_comments=$(echo "$discussion_data" | jq -r --argjson ignored "$IGNORE_BOT_NAMES_JSON" ' ((.data.repository.pullRequest.reviewThreads.nodes // []) | map(select( .isResolved != true and .isOutdated != true and (((.comments.nodes // []) | first | .isMinimized) != true) and ((((.comments.nodes // []) | first | .pullRequestReview.isMinimized) // false) != true) )) | map(.comments.nodes // []) | flatten | map(select((.isMinimized != true) and ((.pullRequestReview.isMinimized // false) != true) and (((.author.login? // "unknown") as $login | $ignored | index($login)) | not)))) | if length > 0 then map("- " + (.author.login? // "unknown") + " at " + (.createdAt // "N/A") + " (" + (.path // "Unknown file") + ":" + ((.line // .originalLine // "N/A") | tostring) + "):\n " + ((.body // "") | tostring) + "\n") | join("") else "No inline review comments." end' 2>"$review_comment_filter_err"); then if [ -s "$review_comment_filter_err" ]; then echo "::debug::jq stderr (review comments) emitted output:" cat "$review_comment_filter_err" fi else echo "::warning::Review comment formatting failed" review_comments="Error processing review comments." echo "FILTER_ERROR_COMMENTS=true" >> $GITHUB_ENV fi rm -f "$review_comment_filter_err" || true # Store filtering statistics echo "EXCLUDED_REVIEWS=$excluded_reviews" >> $GITHUB_ENV echo "EXCLUDED_COMMENTS=$excluded_comments" >> $GITHUB_ENV # Prepare linked issues robustly by fetching each one individually linked_issues_content="" issue_numbers=$(echo "$pr_json" | jq -r '.closingIssuesReferences[].number') if [ -z "$issue_numbers" ]; then linked_issues="No issues are formally linked for closure by this PR." else for number in $issue_numbers; do issue_details_json=$(gh issue view "$number" --repo "${{ github.repository }}" --json title,body 2>/dev/null || echo "{}") issue_title=$(echo "$issue_details_json" | jq -r '.title // "Title not available"') issue_body=$(echo "$issue_details_json" | jq -r '.body // "Body not available"') linked_issues_content+=$(printf "\n #%s\n %s\n \n%s\n\n\n" "$number" "$issue_title" "$issue_body") done linked_issues=$linked_issues_content fi # Prepare cross-references from timeline data references=$(echo "$timeline_data" | jq -r '.[] | select(.event == "cross-referenced") | .source.issue | "- Mentioned in \(.html_url | if contains("/pull/") then "PR" else "Issue" end): #\(.number) - \(.title)"') if [ -z "$references" ]; then references="This PR has not been mentioned in other issues or PRs."; fi # Build filtering summary for AI context # Ensure numeric fallbacks so blanks never appear if variables are empty filter_summary="Context filtering applied: ${excluded_reviews:-0} reviews and ${excluded_comments:-0} review comments excluded from this context." if [ "${FILTER_ERROR_REVIEWS}" = "true" ] || [ "${FILTER_ERROR_COMMENTS}" = "true" ]; then filter_summary="$filter_summary"$'\n'"Warning: Some filtering operations encountered errors. Context may include items that should have been filtered." fi # Assemble the final context block CONTEXT_DELIMITER="GH_PR_CONTEXT_DELIMITER_$(openssl rand -hex 8)" echo "PULL_REQUEST_CONTEXT<<$CONTEXT_DELIMITER" >> "$GITHUB_ENV" echo "Author: $author" >> "$GITHUB_ENV" echo "Created At: $created_at" >> "$GITHUB_ENV" echo "Base Branch (target): $base_branch" >> "$GITHUB_ENV" echo "Head Branch (source): $head_branch" >> "$GITHUB_ENV" echo "State: $state" >> "$GITHUB_ENV" echo "Additions: $additions" >> "$GITHUB_ENV" echo "Deletions: $deletions" >> "$GITHUB_ENV" echo "Total Commits: $total_commits" >> "$GITHUB_ENV" echo "Changed Files: $changed_files_count files" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$title" >> "$GITHUB_ENV" echo "---" >> "$GITHUB_ENV" echo "$body" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$comments" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$reviews" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$review_comments" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$changed_files_list" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$linked_issues" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$references" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$filter_summary" >> "$GITHUB_ENV" echo "" >> "$GITHUB_ENV" echo "$CONTEXT_DELIMITER" >> "$GITHUB_ENV" echo "PR_HEAD_SHA=$(echo "$pr_json" | jq -r .headRefOid)" >> $GITHUB_ENV echo "PR_AUTHOR=$author" >> $GITHUB_ENV echo "BASE_BRANCH=$base_branch" >> $GITHUB_ENV - name: Determine Review Type and Last Reviewed SHA if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' id: review_type env: GH_TOKEN: ${{ steps.setup.outputs.token }} BOT_NAMES_JSON: ${{ env.BOT_NAMES_JSON }} run: | # Robust last summary detection: # 1) Find latest bot-authored item with phrase "This review was generated by an AI assistant." # 2) Find latest bot-authored item containing the marker # 3) If the marker item is the latest, use its SHA. Otherwise, try to obtain commit_id from the latest bot review via REST. # 4) If still not possible, leave SHA empty and log that the agent should locate the last summary in-session. pr_summary_payload=$(gh pr view ${{ env.PR_NUMBER }} --repo ${{ github.repository }} --json comments,reviews) detect_json=$(echo "$pr_summary_payload" | jq -c --argjson bots "$BOT_NAMES_JSON" ' def items: [ (.comments[]? | {type:"comment", body:(.body//""), ts:(.updatedAt // .createdAt // ""), author:(.author.login // "unknown")} ), (.reviews[]? | {type:"review", body:(.body//""), ts:(.submittedAt // .updatedAt // .createdAt // ""), author:(.author.login // "unknown")} ) ] | map(select((.author as $a | $bots | index($a)))); def latest(testexpr): (items | map(select(.body | test(testexpr))) | sort_by(.ts) | last) // {}; { latest_phrase: latest("This review was generated by an AI assistant\\.?"), latest_marker: latest("") } ') latest_phrase_ts=$(echo "$detect_json" | jq -r '.latest_phrase.ts // ""') latest_phrase_type=$(echo "$detect_json" | jq -r '.latest_phrase.type // ""') latest_phrase_body=$(echo "$detect_json" | jq -r '.latest_phrase.body // ""') latest_marker_ts=$(echo "$detect_json" | jq -r '.latest_marker.ts // ""') latest_marker_body=$(echo "$detect_json" | jq -r '.latest_marker.body // ""') # Default outputs echo "is_first_review=false" >> $GITHUB_OUTPUT resolved_sha="" if [ -z "$latest_phrase_ts" ] && [ -z "$latest_marker_ts" ]; then echo "No prior bot summaries found. Treating as first review." echo "is_first_review=true" >> $GITHUB_OUTPUT fi # Prefer the marker if it is the most recent if [ -n "$latest_marker_ts" ] && { [ -z "$latest_phrase_ts" ] || [ "$latest_marker_ts" \> "$latest_phrase_ts" ] || [ "$latest_marker_ts" = "$latest_phrase_ts" ]; }; then resolved_sha=$(printf '%s' "$latest_marker_body" | sed -n 's/.*.*/\1/p') if [ -n "$resolved_sha" ]; then echo "Using latest marker SHA: $resolved_sha" fi fi # If marker not chosen or empty, attempt to resolve from the latest review commit_id if [ -z "$resolved_sha" ] && [ -n "$latest_phrase_ts" ]; then echo "Latest summary lacks marker; attempting commit_id from latest bot review..." reviews_rest=$(gh api "/repos/${{ github.repository }}/pulls/${{ env.PR_NUMBER }}/reviews" || echo '[]') resolved_sha=$(echo "$reviews_rest" | jq -r --argjson bots "$BOT_NAMES_JSON" ' map(select((.user.login as $u | $bots | index($u)))) | sort_by(.submitted_at) | last | .commit_id // "" ') if [ -n "$resolved_sha" ]; then echo "Resolved from latest bot review commit_id: $resolved_sha" fi fi if [ -n "$resolved_sha" ]; then echo "last_reviewed_sha=$resolved_sha" >> $GITHUB_OUTPUT echo "$resolved_sha" > last_review_sha.txt # Keep is_first_review as previously set (default false unless none found) else if [ "${{ steps.review_type.outputs.is_first_review }}" != "true" ]; then :; fi echo "Could not determine last reviewed SHA automatically. Agent will need to identify the last summary in-session." echo "last_reviewed_sha=" >> $GITHUB_OUTPUT echo "" > last_review_sha.txt fi - name: Save secure prompt from base branch if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' run: cp .github/prompts/pr-review.md /tmp/pr-review.md - name: Checkout PR head if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' uses: actions/checkout@v4 with: ref: ${{ env.PR_HEAD_SHA }} token: ${{ steps.setup.outputs.token }} fetch-depth: 0 # Full history needed for diff generation - name: Generate PR Diff for First Review if: (github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true') && steps.review_type.outputs.is_first_review == 'true' id: first_review_diff run: | BASE_BRANCH="${{ env.BASE_BRANCH }}" CURRENT_SHA="${PR_HEAD_SHA}" DIFF_CONTENT="" # Ensure dedicated diff folder exists in the workspace (hidden to avoid accidental use) mkdir -p "$GITHUB_WORKSPACE/.mirrobot_files" echo "Generating full PR diff against base branch: $BASE_BRANCH" # Fetch the base branch to ensure we have it if git fetch origin "$BASE_BRANCH":refs/remotes/origin/"$BASE_BRANCH" 2>/dev/null; then echo "Successfully fetched base branch $BASE_BRANCH." # Find merge base (common ancestor) if MERGE_BASE=$(git merge-base origin/"$BASE_BRANCH" "$CURRENT_SHA" 2>/dev/null); then echo "Found merge base: $MERGE_BASE" # Generate diff from merge base to current commit if DIFF_CONTENT=$(git diff --patch "$MERGE_BASE".."$CURRENT_SHA" 2>/dev/null); then DIFF_SIZE=${#DIFF_CONTENT} DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l) echo "Generated PR diff: $DIFF_LINES lines, $DIFF_SIZE characters" # Truncate if too large (500KB limit to avoid context overflow) if [ $DIFF_SIZE -gt 500000 ]; then echo "::warning::PR diff is very large ($DIFF_SIZE chars). Truncating to 500KB." TRUNCATION_MSG=$'\n\n[DIFF TRUNCATED - PR is very large. Showing first 500KB only. Review scaled to high-impact areas.]' DIFF_CONTENT="${DIFF_CONTENT:0:500000}${TRUNCATION_MSG}" fi # Write diff directly into the repository workspace in the dedicated folder echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" else echo "::warning::Could not generate diff. Using changed files list only." DIFF_CONTENT="(Diff generation failed. Please refer to the changed files list above.)" # Write fallback diff directly into the workspace folder echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" fi else echo "::warning::Could not find merge base between $BASE_BRANCH and $CURRENT_SHA." DIFF_CONTENT="(No common ancestor found. This might be a new branch or orphaned commits.)" # Write fallback diff content directly into the repository workspace folder echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" fi else echo "::warning::Could not fetch base branch $BASE_BRANCH. Using changed files list only." DIFF_CONTENT="(Base branch not available for diff. Please refer to the changed files list above.)" # Write error-case diff directly into the repository workspace folder echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" fi env: BASE_BRANCH: ${{ env.BASE_BRANCH }} - name: Generate Incremental Diff if: (github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true') && steps.review_type.outputs.is_first_review == 'false' && steps.review_type.outputs.last_reviewed_sha != '' id: incremental_diff run: | LAST_SHA=${{ steps.review_type.outputs.last_reviewed_sha }} CURRENT_SHA="${PR_HEAD_SHA}" DIFF_CONTENT="" # Ensure dedicated diff folder exists in the workspace (hidden to avoid accidental use) mkdir -p "$GITHUB_WORKSPACE/.mirrobot_files" echo "Attempting to generate incremental diff from $LAST_SHA to $CURRENT_SHA" # Fetch the last reviewed commit, handle potential errors (e.g., rebased/force-pushed commit) # First try fetching from origin if git fetch origin $LAST_SHA 2>/dev/null || git cat-file -e $LAST_SHA^{commit} 2>/dev/null; then echo "Successfully located $LAST_SHA." # Generate diff, fallback to empty if git diff fails (e.g., no common ancestor) if DIFF_CONTENT=$(git diff --patch $LAST_SHA..$CURRENT_SHA 2>/dev/null); then DIFF_SIZE=${#DIFF_CONTENT} DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l) echo "Generated incremental diff: $DIFF_LINES lines, $DIFF_SIZE characters" # Truncate if too large (500KB limit) if [ $DIFF_SIZE -gt 500000 ]; then echo "::warning::Incremental diff is very large ($DIFF_SIZE chars). Truncating to 500KB." TRUNCATION_MSG=$'\n\n[DIFF TRUNCATED - Changes are very large. Showing first 500KB only.]' DIFF_CONTENT="${DIFF_CONTENT:0:500000}${TRUNCATION_MSG}" fi # Write incremental diff directly into the repository workspace folder echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" else echo "::warning::Could not generate diff between $LAST_SHA and $CURRENT_SHA. Possible rebase/force-push. AI will perform full review." # Ensure an empty incremental diff file exists in the workspace folder as fallback echo "" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" fi else echo "::warning::Failed to fetch last reviewed SHA: $LAST_SHA. This can happen if the commit was part of a force-push or rebase. The AI will perform a full review as a fallback." # Ensure an empty incremental diff file exists in the workspace folder when last-SHA fetch fails echo "" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" fi # Ensure workspace diff files exist even on edge cases (in the hidden folder) [ -f "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" ] || touch "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" [ -f "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" ] || touch "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" - name: Assemble Review Prompt if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' env: REVIEW_TYPE: ${{ steps.review_type.outputs.is_first_review == 'true' && 'FIRST' || 'FOLLOW-UP' }} PR_AUTHOR: ${{ env.PR_AUTHOR }} IS_FIRST_REVIEW: ${{ steps.review_type.outputs.is_first_review }} PR_NUMBER: ${{ env.PR_NUMBER }} GITHUB_REPOSITORY: ${{ github.repository }} PR_HEAD_SHA: ${{ env.PR_HEAD_SHA }} PULL_REQUEST_CONTEXT: ${{ env.PULL_REQUEST_CONTEXT }} run: | # Build DIFF_FILE_PATH pointing to the generated diff in the repository workspace if [ "${{ steps.review_type.outputs.is_first_review }}" = "true" ]; then DIFF_FILE_PATH="$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" else DIFF_FILE_PATH="$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" fi # Substitute variables, embedding PR context and diff file path; DIFF_FILE_PATH kept local to this process TMP_DIR="${RUNNER_TEMP:-/tmp}" VARS='${REVIEW_TYPE} ${PR_AUTHOR} ${IS_FIRST_REVIEW} ${PR_NUMBER} ${GITHUB_REPOSITORY} ${PR_HEAD_SHA} ${PULL_REQUEST_CONTEXT} ${DIFF_FILE_PATH}' DIFF_FILE_PATH="$DIFF_FILE_PATH" envsubst "$VARS" < /tmp/pr-review.md > "$TMP_DIR/assembled_prompt.txt" # Immediately clear large env after use echo "PULL_REQUEST_CONTEXT=" >> "$GITHUB_ENV" # Clear small, now-redundant flags included in the context summary echo "EXCLUDED_REVIEWS=" >> "$GITHUB_ENV" || true echo "EXCLUDED_COMMENTS=" >> "$GITHUB_ENV" || true echo "FILTER_ERROR_REVIEWS=" >> "$GITHUB_ENV" || true echo "FILTER_ERROR_COMMENTS=" >> "$GITHUB_ENV" || true - name: Review PR with OpenCode if: github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true' env: GITHUB_TOKEN: ${{ steps.setup.outputs.token }} OPENCODE_PERMISSION: | { "bash": { "gh*": "allow", "git*": "allow", "jq*": "allow" }, "external_directory": "allow", "webfetch": "deny" } REVIEW_TYPE: ${{ steps.review_type.outputs.is_first_review == 'true' && 'FIRST' || 'FOLLOW-UP' }} PR_AUTHOR: ${{ env.PR_AUTHOR }} IS_FIRST_REVIEW: ${{ steps.review_type.outputs.is_first_review }} PR_NUMBER: ${{ env.PR_NUMBER }} GITHUB_REPOSITORY: ${{ github.repository }} PR_HEAD_SHA: ${{ env.PR_HEAD_SHA }} run: | TMP_DIR="${RUNNER_TEMP:-/tmp}" opencode run --share - < "$TMP_DIR/assembled_prompt.txt" - name: Verify AI Review Footers if: always() && (github.event_name != 'issue_comment' || steps.validate.outputs.should_proceed == 'true') continue-on-error: true env: GH_TOKEN: ${{ steps.setup.outputs.token }} BOT_NAMES_JSON: ${{ env.BOT_NAMES_JSON }} PR_NUMBER: ${{ env.PR_NUMBER }} PR_HEAD_SHA: ${{ env.PR_HEAD_SHA }} run: | set -e # Fail fast on errors # Wait briefly for API consistency sleep 5 echo "Verifying latest bot review for required footers..." # 1. Define a cutoff timestamp (e.g., 2 minutes ago) cutoff_ts=$(date -u -d "2 minutes ago" +"%Y-%m-%dT%H:%M:%SZ") echo "Looking for reviews submitted after: $cutoff_ts" # Retry loop to handle API eventual consistency MAX_RETRIES=3 RETRY_DELAY=5 latest_review_json="" for ((i=1; i<=MAX_RETRIES; i++)); do echo "Attempt $i: Fetching reviews..." if ! reviews=$(gh api "/repos/${{ github.repository }}/pulls/${{ env.PR_NUMBER }}/reviews" --paginate); then echo "::warning::Failed to fetch reviews on attempt $i" sleep $RETRY_DELAY continue fi # Extract latest bot review (id and body) latest_review_json=$(echo "$reviews" | jq -c --argjson bots "$BOT_NAMES_JSON" --arg cutoff "$cutoff_ts" ' map(select(.user.login as $u | $bots | index($u))) | map(select(.submitted_at > $cutoff)) | sort_by(.submitted_at) | last | {id: .databaseId, body: (.body // "")} ') if [ -n "$latest_review_json" ] && [ "$latest_review_json" != "null" ]; then echo "Found recent review." break fi echo "No recent review found yet. Waiting ${RETRY_DELAY}s..." sleep $RETRY_DELAY done if [ -z "$latest_review_json" ] || [ "$latest_review_json" == "null" ]; then echo "::warning::No recent bot review found (within last 2 mins) after $MAX_RETRIES attempts. The AI may have decided not to review, or failed." exit 0 fi review_id=$(echo "$latest_review_json" | jq -r .id) current_body=$(echo "$latest_review_json" | jq -r .body) # Define expected footers EXPECTED_SIGNATURE="_This review was generated by an AI assistant._" EXPECTED_MARKER="" needs_fix=false # Check 1: Signature if [[ "$current_body" != *"$EXPECTED_SIGNATURE"* ]]; then echo "::warning::Missing or malformed AI signature footer." needs_fix=true else echo "✓ Found correct AI signature." fi # Check 2: SHA Marker if [[ "$current_body" != *"$EXPECTED_MARKER"* ]]; then echo "::warning::Missing or malformed last_reviewed_sha footer." needs_fix=true else echo "✓ Found correct SHA marker." fi if [ "$needs_fix" = true ]; then echo "Attempting to auto-correct review $review_id..." # Remove existing/malformed footers using regex (in perl mode for robustness) # 1. Remove signature clean_body=$(echo "$current_body" | perl -0777 -pe 's/\Q_This review was generated by an AI assistant._\E//g') # 2. Remove any sha marker clean_body=$(echo "$clean_body" | perl -0777 -pe 's///g') # 3. Trim trailing whitespace clean_body=$(echo "$clean_body" | sed -e :a -e '/^\n*$/{$d;N;};/\n$/ba') # Construct new body new_body="${clean_body} ${EXPECTED_SIGNATURE} ${EXPECTED_MARKER}" # Update review if gh api --method PUT "/repos/${{ github.repository }}/pulls/${{ env.PR_NUMBER }}/reviews/$review_id" -f body="$new_body"; then echo "::notice::Successfully auto-corrected review footers." exit 0 else echo "::error::Failed to auto-correct review footers." exit 1 fi else echo "Verification passed! No corrections needed." fi