Spaces:
Paused
Paused
| name: Bot Reply on Mention | |
| on: | |
| issue_comment: | |
| types: [created] | |
| jobs: | |
| continuous-reply: | |
| if: ${{ contains(github.event.comment.body, '@mirrobot') || contains(github.event.comment.body, '@mirrobot-agent') }} | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| env: | |
| THREAD_NUMBER: ${{ github.event.issue.number }} | |
| BOT_NAMES_JSON: '["mirrobot", "mirrobot-agent", "mirrobot-agent[bot]"]' | |
| IGNORE_BOT_NAMES_JSON: '["ellipsis-dev"]' | |
| COMMENT_FETCH_LIMIT: '20' | |
| REVIEW_FETCH_LIMIT: '15' | |
| REVIEW_THREAD_FETCH_LIMIT: '20' | |
| THREAD_COMMENT_FETCH_LIMIT: '5' | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Bot Setup | |
| 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 }} | |
| custom-providers-json: ${{ secrets.CUSTOM_PROVIDERS_JSON }} | |
| - name: Add reaction to comment | |
| env: | |
| GH_TOKEN: ${{ steps.setup.outputs.token }} | |
| run: | | |
| gh api \ | |
| --method POST \ | |
| -H "Accept: application/vnd.github+json" \ | |
| /repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \ | |
| -f content='eyes' | |
| - name: Gather Full Thread Context | |
| id: context | |
| env: | |
| GH_TOKEN: ${{ steps.setup.outputs.token }} | |
| BOT_NAMES_JSON: ${{ env.BOT_NAMES_JSON }} | |
| IGNORE_BOT_NAMES_JSON: ${{ env.IGNORE_BOT_NAMES_JSON }} | |
| run: | | |
| # Common Info | |
| echo "NEW_COMMENT_AUTHOR=${{ github.event.comment.user.login }}" >> $GITHUB_ENV | |
| # Use a unique delimiter for safety | |
| COMMENT_DELIMITER="GH_BODY_DELIMITER_$(openssl rand -hex 8)" | |
| { echo "NEW_COMMENT_BODY<<$COMMENT_DELIMITER"; echo "${{ github.event.comment.body }}"; echo "$COMMENT_DELIMITER"; } >> "$GITHUB_ENV" | |
| # Determine if PR or Issue | |
| if [ -n '${{ github.event.issue.pull_request }}' ]; then | |
| IS_PR="true" | |
| else | |
| IS_PR="false" | |
| fi | |
| echo "IS_PR=$IS_PR" >> $GITHUB_OUTPUT | |
| # Define a unique, random delimiter for the main context block | |
| CONTEXT_DELIMITER="GH_CONTEXT_DELIMITER_$(openssl rand -hex 8)" | |
| # Fetch and Format Context based on type | |
| if [[ "$IS_PR" == "true" ]]; then | |
| # Fetch PR data | |
| pr_json=$(gh pr view ${{ env.THREAD_NUMBER }} --repo ${{ github.repository }} --json author,title,body,createdAt,state,headRefName,baseRefName,headRefOid,additions,deletions,commits,files,closingIssuesReferences,headRepository) | |
| # Debug: Output pr_json and review_comments_json for inspection | |
| echo "$pr_json" > pr_json.txt | |
| # Fetch timeline data to find cross-references | |
| timeline_data=$(gh api "/repos/${{ github.repository }}/issues/${{ env.THREAD_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.THREAD_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") | |
| echo "$discussion_data" > discussion_data.txt | |
| # For prompt context | |
| echo "PR_HEAD_SHA=$(echo "$pr_json" | jq -r .headRefOid)" >> $GITHUB_ENV | |
| echo "THREAD_AUTHOR=$(echo "$pr_json" | jq -r .author.login)" >> $GITHUB_ENV | |
| echo "BASE_BRANCH=$(echo "$pr_json" | jq -r .baseRefName)" >> $GITHUB_ENV | |
| # Prepare all variables from JSON | |
| 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)"') | |
| # Prepare changed files list | |
| # 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))"') | |
| # Prepare general PR comments (exclude ignored bots) | |
| 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) ===== | |
| 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)" | |
| # 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 | |
| # Build filtering summary | |
| 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 | |
| # 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 | |
| # Fetch each issue's data separately. This is more reliable for cross-repo issues or permission nuances. | |
| 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 "<issue>\n <number>#%s</number>\n <title>%s</title>\n <body>\n%s\n</body>\n</issue>\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 | |
| # Step 1: Write the header for the multi-line environment variable | |
| echo "THREAD_CONTEXT<<$CONTEXT_DELIMITER" >> "$GITHUB_ENV" | |
| # Step 2: Append the content line by line | |
| echo "Type: Pull Request" >> "$GITHUB_ENV" | |
| echo "PR Number: #${{ env.THREAD_NUMBER }}" >> "$GITHUB_ENV" | |
| echo "Title: $title" >> "$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 "<pull_request_body>" >> "$GITHUB_ENV" | |
| echo "$title" >> "$GITHUB_ENV" | |
| echo "---" >> "$GITHUB_ENV" | |
| echo "$body" >> "$GITHUB_ENV" | |
| echo "</pull_request_body>" >> "$GITHUB_ENV" | |
| echo "<pull_request_comments>" >> "$GITHUB_ENV" | |
| echo "$comments" >> "$GITHUB_ENV" | |
| echo "</pull_request_comments>" >> "$GITHUB_ENV" | |
| echo "<pull_request_reviews>" >> "$GITHUB_ENV" | |
| echo "$reviews" >> "$GITHUB_ENV" | |
| echo "</pull_request_reviews>" >> "$GITHUB_ENV" | |
| echo "<pull_request_review_comments>" >> "$GITHUB_ENV" | |
| echo "$review_comments" >> "$GITHUB_ENV" | |
| echo "</pull_request_review_comments>" >> "$GITHUB_ENV" | |
| echo "<pull_request_changed_files>" >> "$GITHUB_ENV" | |
| echo "$changed_files_list" >> "$GITHUB_ENV" | |
| echo "</pull_request_changed_files>" >> "$GITHUB_ENV" | |
| echo "<linked_issues>" >> "$GITHUB_ENV" | |
| echo "$linked_issues" >> "$GITHUB_ENV" | |
| echo "</linked_issues>" >> "$GITHUB_ENV" | |
| # Step 3: Write the closing delimiter | |
| # Add cross-references and filtering summary to the final context | |
| echo "<cross_references>" >> "$GITHUB_ENV" | |
| echo "$references" >> "$GITHUB_ENV" | |
| echo "</cross_references>" >> "$GITHUB_ENV" | |
| echo "<filtering_summary>" >> "$GITHUB_ENV" | |
| echo "$filter_summary" >> "$GITHUB_ENV" | |
| echo "</filtering_summary>" >> "$GITHUB_ENV" | |
| echo "$CONTEXT_DELIMITER" >> "$GITHUB_ENV" | |
| else # It's an Issue | |
| issue_data=$(gh issue view ${{ env.THREAD_NUMBER }} --repo ${{ github.repository }} --json author,title,body,createdAt,state,comments) | |
| timeline_data=$(gh api "/repos/${{ github.repository }}/issues/${{ env.THREAD_NUMBER }}/timeline") | |
| echo "THREAD_AUTHOR=$(echo "$issue_data" | jq -r .author.login)" >> $GITHUB_ENV | |
| # Prepare metadata | |
| author=$(echo "$issue_data" | jq -r .author.login) | |
| created_at=$(echo "$issue_data" | jq -r .createdAt) | |
| state=$(echo "$issue_data" | jq -r .state) | |
| title=$(echo "$issue_data" | jq -r .title) | |
| body=$(echo "$issue_data" | jq -r '.body // "(No description provided)"') | |
| # Prepare comments (exclude ignored bots) | |
| comments=$(echo "$issue_data" | jq -r --argjson ignored "$IGNORE_BOT_NAMES_JSON" 'if (((.comments // []) | length) > 0) then ((.comments[]? | select((.author.login as $login | $ignored | index($login)) | not)) | "- " + (.author.login // "unknown") + " at " + (.createdAt // "N/A") + ":\n" + ((.body // "") | tostring) + "\n") else "No comments have been posted yet." end') | |
| # Prepare cross-references | |
| 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="No other issues or PRs have mentioned this thread."; fi | |
| # Step 1: Write the header | |
| echo "THREAD_CONTEXT<<$CONTEXT_DELIMITER" >> "$GITHUB_ENV" | |
| # Step 2: Append the content line by line | |
| echo "Type: Issue" >> "$GITHUB_ENV" | |
| echo "Issue Number: #${{ env.THREAD_NUMBER }}" >> "$GITHUB_ENV" | |
| echo "Title: $title" >> "$GITHUB_ENV" | |
| echo "Author: $author" >> "$GITHUB_ENV" | |
| echo "Created At: $created_at" >> "$GITHUB_ENV" | |
| echo "State: $state" >> "$GITHUB_ENV" | |
| echo "<issue_body>" >> "$GITHUB_ENV" | |
| echo "$body" >> "$GITHUB_ENV" | |
| echo "</issue_body>" >> "$GITHUB_ENV" | |
| echo "<issue_comments>" >> "$GITHUB_ENV" | |
| echo "$comments" >> "$GITHUB_ENV" | |
| echo "</issue_comments>" >> "$GITHUB_ENV" | |
| echo "<cross_references>" >> "$GITHUB_ENV" | |
| echo "$references" >> "$GITHUB_ENV" | |
| echo "</cross_references>" >> "$GITHUB_ENV" | |
| # Step 3: Write the footer | |
| echo "$CONTEXT_DELIMITER" >> "$GITHUB_ENV" | |
| fi | |
| - name: Clear pending bot review | |
| if: steps.context.outputs.IS_PR == '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.THREAD_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.THREAD_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: Determine Review Type and Last Reviewed SHA | |
| if: steps.context.outputs.IS_PR == 'true' | |
| id: review_type | |
| env: | |
| GH_TOKEN: ${{ steps.setup.outputs.token }} | |
| BOT_NAMES_JSON: ${{ env.BOT_NAMES_JSON }} | |
| run: | | |
| pr_summary_payload=$(gh pr view ${{ env.THREAD_NUMBER }} --repo ${{ github.repository }} --json comments,reviews) | |
| detect_json=$(echo "$pr_summary_payload" | jq -c --argjson bots "$BOT_NAMES_JSON" ' | |
| def ts(x): if (x//""=="") then null else x end; | |
| def items: | |
| [ (.comments[]? | select(.author.login as $a | $bots | index($a)) | {type:"comment", body:(.body//""), ts:(.updatedAt // .createdAt // "")} ), | |
| (.reviews[]? | select(.author.login as $a | $bots | index($a)) | {type:"review", body:(.body//""), ts:(.submittedAt // .updatedAt // .createdAt // "")} ) | |
| ] | sort_by(.ts) | .; | |
| def has_phrase: (.body//"") | test("This review was generated by an AI assistant\\.?"); | |
| def has_marker: (.body//"") | test("<!--\\s*last_reviewed_sha:[a-f0-9]{7,40}\\s*-->"); | |
| { latest_phrase: (items | map(select(has_phrase)) | last // {}), | |
| latest_marker: (items | map(select(has_marker)) | last // {}) } | |
| ') | |
| latest_phrase_ts=$(echo "$detect_json" | jq -r '.latest_phrase.ts // ""') | |
| latest_marker_ts=$(echo "$detect_json" | jq -r '.latest_marker.ts // ""') | |
| latest_marker_body=$(echo "$detect_json" | jq -r '.latest_marker.body // ""') | |
| echo "is_first_review=false" >> $GITHUB_OUTPUT | |
| resolved_sha="" | |
| if [ -z "$latest_phrase_ts" ] && [ -z "$latest_marker_ts" ]; then | |
| echo "is_first_review=true" >> $GITHUB_OUTPUT | |
| fi | |
| 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 -nE 's/.*<!--\s*last_reviewed_sha:([a-f0-9]{7,40})\s*-->.*/\1/p' | head -n1) | |
| fi | |
| if [ -z "$resolved_sha" ] && [ -n "$latest_phrase_ts" ]; then | |
| reviews_json=$(gh api "/repos/${{ github.repository }}/pulls/${{ env.THREAD_NUMBER }}/reviews" || echo '[]') | |
| resolved_sha=$(echo "$reviews_json" | jq -r --argjson bots "$BOT_NAMES_JSON" '[.[] | select((.user.login // "") as $u | $bots | index($u)) | .commit_id] | last // ""') | |
| fi | |
| if [ -n "$resolved_sha" ]; then | |
| echo "last_reviewed_sha=$resolved_sha" >> $GITHUB_OUTPUT | |
| echo "$resolved_sha" > last_review_sha.txt | |
| else | |
| echo "last_reviewed_sha=" >> $GITHUB_OUTPUT | |
| echo "" > last_review_sha.txt | |
| fi | |
| - name: Save secure prompt from base branch | |
| run: cp .github/prompts/bot-reply.md /tmp/bot-reply.md | |
| - name: Checkout PR head | |
| if: steps.context.outputs.IS_PR == 'true' | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ env.PR_HEAD_SHA }} | |
| token: ${{ steps.setup.outputs.token }} | |
| fetch-depth: 0 # Full history needed for git operations and code analysis | |
| - name: Generate PR Diffs (Full and Incremental) | |
| if: steps.context.outputs.IS_PR == 'true' | |
| id: generate_diffs | |
| env: | |
| BASE_BRANCH: ${{ env.BASE_BRANCH }} | |
| run: | | |
| mkdir -p "$GITHUB_WORKSPACE/.mirrobot_files" | |
| BASE_BRANCH="${BASE_BRANCH}" | |
| CURRENT_SHA="${PR_HEAD_SHA}" | |
| LAST_SHA="${{ steps.review_type.outputs.last_reviewed_sha }}" | |
| # Always generate full diff against base branch | |
| echo "Generating full PR diff against base branch: $BASE_BRANCH" | |
| if git fetch origin "$BASE_BRANCH":refs/remotes/origin/"$BASE_BRANCH" 2>/dev/null; then | |
| if MERGE_BASE=$(git merge-base origin/"$BASE_BRANCH" "$CURRENT_SHA" 2>/dev/null); then | |
| if DIFF_CONTENT=$(git diff --patch "$MERGE_BASE".."$CURRENT_SHA" 2>/dev/null); then | |
| DIFF_SIZE=${#DIFF_CONTENT} | |
| if [ $DIFF_SIZE -gt 500000 ]; then | |
| 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 | |
| echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" | |
| echo "Full diff generated ($(echo "$DIFF_CONTENT" | wc -l) lines)" | |
| else | |
| echo "(Diff generation failed. Please refer to the changed files list above.)" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" | |
| fi | |
| else | |
| echo "(No common ancestor found. This might be a new branch or orphaned commits.)" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" | |
| fi | |
| else | |
| echo "(Base branch not available for diff. Please refer to the changed files list above.)" > "$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" | |
| fi | |
| # Generate incremental diff if this is a follow-up review | |
| if [ -n "$LAST_SHA" ]; then | |
| echo "Generating incremental diff from $LAST_SHA to $CURRENT_SHA" | |
| if git fetch origin $LAST_SHA 2>/dev/null || git cat-file -e $LAST_SHA^{commit} 2>/dev/null; then | |
| if DIFF_CONTENT=$(git diff --patch $LAST_SHA..$CURRENT_SHA 2>/dev/null); then | |
| DIFF_SIZE=${#DIFF_CONTENT} | |
| if [ $DIFF_SIZE -gt 500000 ]; then | |
| TRUNCATION_MSG=$'\n\n[DIFF TRUNCATED - Changes are very large. Showing first 500KB only.]' | |
| DIFF_CONTENT="${DIFF_CONTENT:0:500000}${TRUNCATION_MSG}" | |
| fi | |
| echo "$DIFF_CONTENT" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" | |
| echo "Incremental diff generated ($(echo "$DIFF_CONTENT" | wc -l) lines)" | |
| else | |
| echo "(Unable to generate incremental diff.)" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" | |
| fi | |
| else | |
| echo "(Last reviewed SHA not accessible for incremental diff.)" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" | |
| fi | |
| else | |
| echo "(No previous review - incremental diff not applicable.)" > "$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" | |
| fi | |
| - name: Checkout repository (for issues) | |
| if: steps.context.outputs.IS_PR == 'false' | |
| uses: actions/checkout@v4 | |
| with: | |
| token: ${{ steps.setup.outputs.token }} | |
| fetch-depth: 0 # Full history needed for git operations and code analysis | |
| - name: Analyze comment and respond | |
| env: | |
| GITHUB_TOKEN: ${{ steps.setup.outputs.token }} | |
| THREAD_CONTEXT: ${{ env.THREAD_CONTEXT }} | |
| NEW_COMMENT_AUTHOR: ${{ env.NEW_COMMENT_AUTHOR }} | |
| NEW_COMMENT_BODY: ${{ env.NEW_COMMENT_BODY }} | |
| THREAD_NUMBER: ${{ env.THREAD_NUMBER }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| THREAD_AUTHOR: ${{ env.THREAD_AUTHOR }} | |
| PR_HEAD_SHA: ${{ env.PR_HEAD_SHA }} | |
| IS_FIRST_REVIEW: ${{ steps.review_type.outputs.is_first_review }} | |
| OPENCODE_PERMISSION: | | |
| { | |
| "bash": { | |
| "gh*": "allow", | |
| "git*": "allow", | |
| "jq*": "allow" | |
| }, | |
| "external_directory": "allow", | |
| "webfetch": "deny" | |
| } | |
| run: | | |
| # Only substitute the variables we intend; leave example $vars and secrets intact | |
| if [ "${{ steps.context.outputs.IS_PR }}" = "true" ]; then | |
| FULL_DIFF_PATH="$GITHUB_WORKSPACE/.mirrobot_files/first_review_diff.txt" | |
| INCREMENTAL_DIFF_PATH="$GITHUB_WORKSPACE/.mirrobot_files/incremental_diff.txt" | |
| LAST_REVIEWED_SHA="${{ steps.review_type.outputs.last_reviewed_sha }}" | |
| else | |
| FULL_DIFF_PATH="" | |
| INCREMENTAL_DIFF_PATH="" | |
| LAST_REVIEWED_SHA="" | |
| fi | |
| VARS='$THREAD_CONTEXT $NEW_COMMENT_AUTHOR $NEW_COMMENT_BODY $THREAD_NUMBER $GITHUB_REPOSITORY $THREAD_AUTHOR $PR_HEAD_SHA $IS_FIRST_REVIEW $FULL_DIFF_PATH $INCREMENTAL_DIFF_PATH $LAST_REVIEWED_SHA' | |
| FULL_DIFF_PATH="$FULL_DIFF_PATH" INCREMENTAL_DIFF_PATH="$INCREMENTAL_DIFF_PATH" LAST_REVIEWED_SHA="$LAST_REVIEWED_SHA" envsubst "$VARS" < /tmp/bot-reply.md | opencode run --share - |