|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | +# Licensed under the MIT License. |
| 4 | +# |
| 5 | +# Multi-perspective PR review using GitHub Models API. |
| 6 | +# Called by the perspective-review.yml workflow. |
| 7 | +# |
| 8 | +# Usage: perspective-review.sh <repo> <pr_number> |
| 9 | +# Requires: GITHUB_TOKEN env var, jq, gh CLI |
| 10 | + |
| 11 | +set -euo pipefail |
| 12 | + |
| 13 | +REPO="$1" |
| 14 | +PR_NUMBER="$2" |
| 15 | + |
| 16 | +echo "=== Perspective Review for ${REPO}#${PR_NUMBER} ===" |
| 17 | + |
| 18 | +# Step 1: Get changed files |
| 19 | +echo "Fetching changed files..." |
| 20 | +gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" \ |
| 21 | + --jq '.[].filename' > /tmp/changed_files.txt |
| 22 | +echo "Changed files: $(wc -l < /tmp/changed_files.txt)" |
| 23 | + |
| 24 | +# Step 2: Get the diff (truncate to ~60KB for token limits) |
| 25 | +echo "Fetching diff..." |
| 26 | +gh api "repos/${REPO}/pulls/${PR_NUMBER}" \ |
| 27 | + -H "Accept: application/vnd.github.v3.diff" \ |
| 28 | + | head -c 60000 > /tmp/pr_diff.txt |
| 29 | +echo "Diff size: $(wc -c < /tmp/pr_diff.txt) bytes" |
| 30 | + |
| 31 | +# Step 3: Select perspectives based on changed paths |
| 32 | +PERSPECTIVES="reliability-engineer,test-engineer" |
| 33 | + |
| 34 | +if grep -qE 'src/builtins/' /tmp/changed_files.txt 2>/dev/null; then |
| 35 | + PERSPECTIVES="${PERSPECTIVES},semantics-expert,red-teamer" |
| 36 | +fi |
| 37 | +if grep -qE 'src/(value|number)' /tmp/changed_files.txt 2>/dev/null; then |
| 38 | + PERSPECTIVES="${PERSPECTIVES},semantics-expert" |
| 39 | +fi |
| 40 | +if grep -qE 'bindings/|src/.*ffi' /tmp/changed_files.txt 2>/dev/null; then |
| 41 | + PERSPECTIVES="${PERSPECTIVES},architect,api-steward" |
| 42 | +fi |
| 43 | +if grep -qE 'Cargo\.(toml|lock)' /tmp/changed_files.txt 2>/dev/null; then |
| 44 | + PERSPECTIVES="${PERSPECTIVES},security-auditor,architect" |
| 45 | +fi |
| 46 | +if grep -qE 'src/(interpreter|rvm|compiler|scheduler)' /tmp/changed_files.txt 2>/dev/null; then |
| 47 | + PERSPECTIVES="${PERSPECTIVES},semantics-expert,performance-engineer" |
| 48 | +fi |
| 49 | + |
| 50 | +# Deduplicate |
| 51 | +PERSPECTIVES=$(echo "$PERSPECTIVES" | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//') |
| 52 | +echo "Selected perspectives: ${PERSPECTIVES}" |
| 53 | + |
| 54 | +# Step 4: Build context |
| 55 | +echo "Building context..." |
| 56 | +KNOWLEDGE_CTX="" |
| 57 | +for f in builtin-system value-semantics policy-evaluation-security ffi-boundary; do |
| 58 | + kf="docs/knowledge/${f}.md" |
| 59 | + if [ -f "$kf" ]; then |
| 60 | + KNOWLEDGE_CTX="${KNOWLEDGE_CTX} |
| 61 | +--- ${f}.md --- |
| 62 | +$(head -c 4000 "$kf") |
| 63 | +" |
| 64 | + fi |
| 65 | +done |
| 66 | + |
| 67 | +DIFF_CONTENT=$(cat /tmp/pr_diff.txt) |
| 68 | + |
| 69 | +# Step 5: Run each perspective |
| 70 | +ALL_FINDINGS="" |
| 71 | + |
| 72 | +for perspective in $(echo "$PERSPECTIVES" | tr ',' ' '); do |
| 73 | + echo "--- Reviewing: ${perspective} ---" |
| 74 | + |
| 75 | + # Load agent instructions |
| 76 | + AGENT_INSTRUCTIONS="" |
| 77 | + agent_file=".github/agents/${perspective}.agent.md" |
| 78 | + if [ -f "$agent_file" ]; then |
| 79 | + AGENT_INSTRUCTIONS=$(head -c 4000 "$agent_file") |
| 80 | + fi |
| 81 | + |
| 82 | + # Format display name |
| 83 | + DISPLAY_NAME=$(echo "$perspective" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2)}1') |
| 84 | + |
| 85 | + # Build prompt as a temp file to avoid heredoc/quoting issues |
| 86 | + cat > /tmp/review_prompt.txt <<EOF |
| 87 | +You are reviewing a pull request from the perspective of a ${DISPLAY_NAME}. |
| 88 | +
|
| 89 | +Your agent instructions: |
| 90 | +${AGENT_INSTRUCTIONS} |
| 91 | +
|
| 92 | +Relevant knowledge context: |
| 93 | +${KNOWLEDGE_CTX} |
| 94 | +
|
| 95 | +PR Diff: |
| 96 | +${DIFF_CONTENT} |
| 97 | +
|
| 98 | +Respond with a JSON array of findings. Each finding must have these fields: |
| 99 | +- "severity": one of "critical", "important", "suggestion" |
| 100 | +- "summary": a single sentence suitable as a GitHub issue title |
| 101 | +- "file": the file path (from the diff) |
| 102 | +- "line": approximate line number (from the diff), or null |
| 103 | +- "explanation": 2-4 sentences explaining the issue |
| 104 | +
|
| 105 | +If you find no issues from this perspective, return an empty array: [] |
| 106 | +
|
| 107 | +Return ONLY valid JSON — no markdown fences, no extra text. |
| 108 | +EOF |
| 109 | + |
| 110 | + PROMPT_CONTENT=$(cat /tmp/review_prompt.txt) |
| 111 | + |
| 112 | + # Call GitHub Models API using jq for safe JSON encoding |
| 113 | + RESPONSE=$(jq -n \ |
| 114 | + --arg model "openai/gpt-4o-mini" \ |
| 115 | + --arg prompt "$PROMPT_CONTENT" \ |
| 116 | + '{ |
| 117 | + model: $model, |
| 118 | + messages: [ |
| 119 | + {role: "system", content: "You are a code reviewer. Return findings as a JSON array only."}, |
| 120 | + {role: "user", content: $prompt} |
| 121 | + ], |
| 122 | + temperature: 0.1 |
| 123 | + }' | curl -s -X POST "https://models.github.ai/inference/chat/completions" \ |
| 124 | + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ |
| 125 | + -H "Content-Type: application/json" \ |
| 126 | + -d @- 2>/dev/null || echo '{"error": "API call failed"}') |
| 127 | + |
| 128 | + # Extract content |
| 129 | + CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty' 2>/dev/null || true) |
| 130 | + |
| 131 | + if [ -z "$CONTENT" ]; then |
| 132 | + ERROR_MSG=$(echo "$RESPONSE" | jq -r '.error // .message // "Unknown error"' 2>/dev/null || echo "Unknown error") |
| 133 | + echo " API error: ${ERROR_MSG}" |
| 134 | + continue |
| 135 | + fi |
| 136 | + |
| 137 | + # Try to parse as JSON array and render as tagged markdown |
| 138 | + RENDERED=$(echo "$CONTENT" | jq -r --arg perspective "$DISPLAY_NAME" ' |
| 139 | + if type == "array" then |
| 140 | + .[] | |
| 141 | + "**[\($perspective)]** " + |
| 142 | + (if .severity == "critical" then "🔴 critical" elif .severity == "important" then "🟠 important" else "🔵 suggestion" end) + |
| 143 | + "\n> " + .summary + "\n\n" + .explanation + |
| 144 | + (if .file then "\n\n📁 `" + .file + "`" + (if .line then ":" + (.line | tostring) else "" end) else "" end) + |
| 145 | + "\n\n---\n" |
| 146 | + else |
| 147 | + empty |
| 148 | + end |
| 149 | + ' 2>/dev/null || true) |
| 150 | + |
| 151 | + if [ -n "$RENDERED" ]; then |
| 152 | + FINDING_COUNT=$(echo "$CONTENT" | jq 'if type == "array" then length else 0 end' 2>/dev/null || echo 0) |
| 153 | + echo " Found ${FINDING_COUNT} findings" |
| 154 | + ALL_FINDINGS="${ALL_FINDINGS}${RENDERED}" |
| 155 | + else |
| 156 | + echo " No findings (or unparseable response)" |
| 157 | + fi |
| 158 | +done |
| 159 | + |
| 160 | +# Step 6: Build the final comment |
| 161 | +MARKER="<!-- perspective-review-bot -->" |
| 162 | + |
| 163 | +if [ -n "$ALL_FINDINGS" ]; then |
| 164 | + cat > /tmp/review_comment.md <<EOF |
| 165 | +${MARKER} |
| 166 | +## 🔍 Perspective Review |
| 167 | +
|
| 168 | +Automated multi-perspective review of this PR. |
| 169 | +Each finding is tagged with the perspective that identified it. |
| 170 | +
|
| 171 | +--- |
| 172 | +
|
| 173 | +$(echo -e "$ALL_FINDINGS") |
| 174 | +
|
| 175 | +<sub>Generated by perspective-review workflow • Perspectives: ${PERSPECTIVES}</sub> |
| 176 | +EOF |
| 177 | +else |
| 178 | + cat > /tmp/review_comment.md <<EOF |
| 179 | +${MARKER} |
| 180 | +## 🔍 Perspective Review |
| 181 | +
|
| 182 | +✅ No significant findings from the selected perspectives. |
| 183 | +
|
| 184 | +<sub>Generated by perspective-review workflow • Perspectives: ${PERSPECTIVES}</sub> |
| 185 | +EOF |
| 186 | +fi |
| 187 | + |
| 188 | +# Step 7: Upsert the comment (update existing or create new) |
| 189 | +echo "Posting review comment..." |
| 190 | +EXISTING_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ |
| 191 | + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ |
| 192 | + | head -1 || true) |
| 193 | + |
| 194 | +COMMENT_BODY=$(cat /tmp/review_comment.md) |
| 195 | + |
| 196 | +if [ -n "$EXISTING_ID" ]; then |
| 197 | + gh api "repos/${REPO}/issues/comments/${EXISTING_ID}" \ |
| 198 | + -X PATCH \ |
| 199 | + -f body="$COMMENT_BODY" |
| 200 | + echo "Updated existing comment ${EXISTING_ID}" |
| 201 | +else |
| 202 | + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ |
| 203 | + -f body="$COMMENT_BODY" |
| 204 | + echo "Created new review comment" |
| 205 | +fi |
| 206 | + |
| 207 | +echo "=== Review complete ===" |
0 commit comments