|
| 1 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 2 | +# Licensed under the MIT License. |
| 3 | + |
| 4 | +# Multi-perspective AI code review using GitHub Models API. |
| 5 | +# Posts a single consolidated review comment with perspective-tagged findings. |
| 6 | +# |
| 7 | +# Design: |
| 8 | +# 1. Triggered on PRs (not fork-safe via pull_request_target — uses API-only diff fetch) |
| 9 | +# 2. Selects relevant perspectives based on changed file paths |
| 10 | +# 3. Calls GitHub Models API with agent instructions + file context + diff |
| 11 | +# 4. Requests structured JSON output, renders tagged markdown |
| 12 | +# 5. Upserts a single PR comment (idempotent on push updates) |
| 13 | + |
| 14 | +name: Perspective Review |
| 15 | + |
| 16 | +on: |
| 17 | + # Use pull_request_target so we have write access and secrets, |
| 18 | + # but NEVER checkout the PR head — only fetch diff/files via API. |
| 19 | + pull_request_target: |
| 20 | + types: [opened, synchronize] |
| 21 | + |
| 22 | +# Only one review per PR at a time; cancel stale runs. |
| 23 | +concurrency: |
| 24 | + group: perspective-review-${{ github.event.pull_request.number }} |
| 25 | + cancel-in-progress: true |
| 26 | + |
| 27 | +permissions: |
| 28 | + contents: read |
| 29 | + pull-requests: write |
| 30 | + models: read |
| 31 | + |
| 32 | +jobs: |
| 33 | + perspective-review: |
| 34 | + runs-on: ubuntu-latest |
| 35 | + # Skip draft PRs and bot PRs |
| 36 | + if: > |
| 37 | + !github.event.pull_request.draft && |
| 38 | + github.event.pull_request.user.login != 'dependabot[bot]' |
| 39 | +
|
| 40 | + steps: |
| 41 | + - name: Checkout base branch only (security: never checkout PR head) |
| 42 | + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| 43 | + with: |
| 44 | + ref: ${{ github.event.pull_request.base.ref }} |
| 45 | + |
| 46 | + - name: Gather PR metadata |
| 47 | + id: meta |
| 48 | + env: |
| 49 | + GH_TOKEN: ${{ github.token }} |
| 50 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 51 | + run: | |
| 52 | + # Get changed files |
| 53 | + gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/files" \ |
| 54 | + --jq '.[].filename' > changed_files.txt |
| 55 | +
|
| 56 | + # Get the diff (truncate to ~60KB to stay within token limits) |
| 57 | + gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" \ |
| 58 | + -H "Accept: application/vnd.github.v3.diff" \ |
| 59 | + | head -c 60000 > pr_diff.txt |
| 60 | +
|
| 61 | + echo "file_count=$(wc -l < changed_files.txt)" >> "$GITHUB_OUTPUT" |
| 62 | +
|
| 63 | + - name: Select perspectives |
| 64 | + id: perspectives |
| 65 | + run: | |
| 66 | + # Always-on base perspectives |
| 67 | + PERSPECTIVES="reliability-engineer,test-engineer" |
| 68 | +
|
| 69 | + # Add specialists based on changed paths |
| 70 | + if grep -qE 'src/builtins/' changed_files.txt; then |
| 71 | + PERSPECTIVES="${PERSPECTIVES},semantics-expert,red-teamer" |
| 72 | + fi |
| 73 | + if grep -qE 'src/(value|number)' changed_files.txt; then |
| 74 | + PERSPECTIVES="${PERSPECTIVES},semantics-expert" |
| 75 | + fi |
| 76 | + if grep -qE 'bindings/|src/.*ffi' changed_files.txt; then |
| 77 | + PERSPECTIVES="${PERSPECTIVES},architect,api-steward" |
| 78 | + fi |
| 79 | + if grep -qE 'Cargo\.(toml|lock)' changed_files.txt; then |
| 80 | + PERSPECTIVES="${PERSPECTIVES},security-auditor,architect" |
| 81 | + fi |
| 82 | + if grep -qE 'src/(interpreter|rvm|compiler|scheduler)' changed_files.txt; then |
| 83 | + PERSPECTIVES="${PERSPECTIVES},semantics-expert,performance-engineer" |
| 84 | + fi |
| 85 | + if grep -qE '\.github/' changed_files.txt; then |
| 86 | + PERSPECTIVES="${PERSPECTIVES},ci-engineer" |
| 87 | + fi |
| 88 | +
|
| 89 | + # Deduplicate |
| 90 | + PERSPECTIVES=$(echo "$PERSPECTIVES" | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//') |
| 91 | + echo "selected=${PERSPECTIVES}" >> "$GITHUB_OUTPUT" |
| 92 | + echo "Selected perspectives: ${PERSPECTIVES}" |
| 93 | +
|
| 94 | + - name: Build context files |
| 95 | + run: | |
| 96 | + mkdir -p review_context |
| 97 | +
|
| 98 | + # Collect relevant knowledge docs based on perspectives |
| 99 | + for perspective in $(echo "${{ steps.perspectives.outputs.selected }}" | tr ',' ' '); do |
| 100 | + agent_file=".github/agents/${perspective}.agent.md" |
| 101 | + if [ -f "$agent_file" ]; then |
| 102 | + cp "$agent_file" "review_context/${perspective}.agent.md" |
| 103 | + fi |
| 104 | + done |
| 105 | +
|
| 106 | + # Include key knowledge files for context (keep small — only most relevant) |
| 107 | + for f in builtin-system value-semantics policy-evaluation-security ffi-boundary; do |
| 108 | + kf="docs/knowledge/${f}.md" |
| 109 | + if [ -f "$kf" ]; then |
| 110 | + # Truncate large files to 4KB |
| 111 | + head -c 4000 "$kf" > "review_context/${f}.md" |
| 112 | + fi |
| 113 | + done |
| 114 | +
|
| 115 | + # Include surrounding code for changed source files (first 3 .rs files) |
| 116 | + while IFS= read -r file; do |
| 117 | + if [[ "$file" == *.rs ]] && [ -f "$file" ]; then |
| 118 | + mkdir -p "review_context/src" |
| 119 | + # Include full file if < 8KB, otherwise first+last 3KB |
| 120 | + if [ "$(wc -c < "$file")" -lt 8000 ]; then |
| 121 | + cp "$file" "review_context/src/$(basename "$file")" |
| 122 | + else |
| 123 | + { head -c 3000 "$file"; echo -e "\n\n... [truncated] ...\n\n"; tail -c 3000 "$file"; } \ |
| 124 | + > "review_context/src/$(basename "$file")" |
| 125 | + fi |
| 126 | + fi |
| 127 | + done < <(head -3 changed_files.txt) |
| 128 | +
|
| 129 | + - name: Run perspective reviews |
| 130 | + id: review |
| 131 | + env: |
| 132 | + GITHUB_TOKEN: ${{ github.token }} |
| 133 | + run: | |
| 134 | + PERSPECTIVES="${{ steps.perspectives.outputs.selected }}" |
| 135 | + DIFF=$(cat pr_diff.txt) |
| 136 | + ALL_FINDINGS="" |
| 137 | +
|
| 138 | + for perspective in $(echo "$PERSPECTIVES" | tr ',' ' '); do |
| 139 | + echo "=== Reviewing from: ${perspective} ===" |
| 140 | +
|
| 141 | + # Build the agent context |
| 142 | + AGENT_INSTRUCTIONS="" |
| 143 | + agent_file="review_context/${perspective}.agent.md" |
| 144 | + if [ -f "$agent_file" ]; then |
| 145 | + AGENT_INSTRUCTIONS=$(cat "$agent_file") |
| 146 | + fi |
| 147 | +
|
| 148 | + # Build knowledge context |
| 149 | + KNOWLEDGE="" |
| 150 | + for kf in review_context/*.md; do |
| 151 | + [ -f "$kf" ] && KNOWLEDGE="${KNOWLEDGE}\n--- $(basename "$kf") ---\n$(cat "$kf")\n" |
| 152 | + done |
| 153 | +
|
| 154 | + # Build source context |
| 155 | + SOURCE_CTX="" |
| 156 | + for sf in review_context/src/*.rs 2>/dev/null; do |
| 157 | + [ -f "$sf" ] && SOURCE_CTX="${SOURCE_CTX}\n--- $(basename "$sf") ---\n$(cat "$sf")\n" |
| 158 | + done |
| 159 | +
|
| 160 | + # Format the perspective name for display |
| 161 | + DISPLAY_NAME=$(echo "$perspective" | sed 's/-/ /g' | sed 's/\b\(.\)/\u\1/g') |
| 162 | +
|
| 163 | + # Create the prompt |
| 164 | + PROMPT=$(cat <<PROMPT_EOF |
| 165 | + You are reviewing a pull request from the perspective of a ${DISPLAY_NAME}. |
| 166 | +
|
| 167 | + Your agent instructions: |
| 168 | + ${AGENT_INSTRUCTIONS} |
| 169 | +
|
| 170 | + Relevant knowledge context: |
| 171 | + ${KNOWLEDGE} |
| 172 | +
|
| 173 | + Source files for context: |
| 174 | + ${SOURCE_CTX} |
| 175 | +
|
| 176 | + PR Diff: |
| 177 | + ${DIFF} |
| 178 | +
|
| 179 | + Respond with a JSON array of findings. Each finding must have these fields: |
| 180 | + - "severity": one of "critical", "important", "suggestion" |
| 181 | + - "summary": a single sentence suitable as a GitHub issue title |
| 182 | + - "file": the file path (from the diff) |
| 183 | + - "line": approximate line number (from the diff), or null |
| 184 | + - "explanation": 2-4 sentences explaining the issue |
| 185 | +
|
| 186 | + If you find no issues from this perspective, return an empty array: [] |
| 187 | +
|
| 188 | + Return ONLY valid JSON — no markdown fences, no extra text. |
| 189 | + PROMPT_EOF |
| 190 | + ) |
| 191 | +
|
| 192 | + # Call GitHub Models API |
| 193 | + RESPONSE=$(curl -s -X POST "https://models.github.ai/inference/chat/completions" \ |
| 194 | + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ |
| 195 | + -H "Content-Type: application/json" \ |
| 196 | + -d "$(jq -n \ |
| 197 | + --arg model "openai/gpt-4o-mini" \ |
| 198 | + --arg prompt "$PROMPT" \ |
| 199 | + '{ |
| 200 | + model: $model, |
| 201 | + messages: [ |
| 202 | + {role: "system", content: "You are a code reviewer. Return findings as a JSON array only."}, |
| 203 | + {role: "user", content: $prompt} |
| 204 | + ], |
| 205 | + temperature: 0.1 |
| 206 | + }')" 2>/dev/null || echo '{"error": "API call failed"}') |
| 207 | +
|
| 208 | + # Extract the content from the response |
| 209 | + CONTENT=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // "[]"' 2>/dev/null || echo "[]") |
| 210 | +
|
| 211 | + # Parse findings and render as tagged markdown |
| 212 | + SEVERITY_MAP='{"critical": "🔴", "important": "🟠", "suggestion": "🔵"}' |
| 213 | +
|
| 214 | + RENDERED=$(echo "$CONTENT" | jq -r --arg perspective "$DISPLAY_NAME" --argjson smap "$SEVERITY_MAP" ' |
| 215 | + if type == "array" then |
| 216 | + .[] | |
| 217 | + "**[\($perspective)]** \($smap[.severity] // "🔵") \(.severity)\n> \(.summary)\n\n\(.explanation)\n\(.file):\(.line // "?")\n\n---\n" |
| 218 | + else |
| 219 | + empty |
| 220 | + end |
| 221 | + ' 2>/dev/null || echo "") |
| 222 | +
|
| 223 | + if [ -n "$RENDERED" ]; then |
| 224 | + ALL_FINDINGS="${ALL_FINDINGS}${RENDERED}" |
| 225 | + fi |
| 226 | + done |
| 227 | +
|
| 228 | + # Save findings to file |
| 229 | + if [ -n "$ALL_FINDINGS" ]; then |
| 230 | + { |
| 231 | + echo "## 🔍 Perspective Review" |
| 232 | + echo "" |
| 233 | + echo "Automated multi-perspective review of this PR." |
| 234 | + echo "Each finding is tagged with the perspective that identified it." |
| 235 | + echo "" |
| 236 | + echo "---" |
| 237 | + echo "" |
| 238 | + echo -e "$ALL_FINDINGS" |
| 239 | + echo "" |
| 240 | + echo "<sub>Generated by perspective-review workflow • Perspectives: ${PERSPECTIVES}</sub>" |
| 241 | + } > review_comment.md |
| 242 | + else |
| 243 | + { |
| 244 | + echo "## 🔍 Perspective Review" |
| 245 | + echo "" |
| 246 | + echo "✅ No significant findings from the selected perspectives." |
| 247 | + echo "" |
| 248 | + echo "<sub>Generated by perspective-review workflow • Perspectives: ${PERSPECTIVES}</sub>" |
| 249 | + } > review_comment.md |
| 250 | + fi |
| 251 | +
|
| 252 | + echo "has_findings=$([ -n "$ALL_FINDINGS" ] && echo true || echo false)" >> "$GITHUB_OUTPUT" |
| 253 | +
|
| 254 | + - name: Post or update review comment |
| 255 | + env: |
| 256 | + GH_TOKEN: ${{ github.token }} |
| 257 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 258 | + run: | |
| 259 | + MARKER="<!-- perspective-review-bot -->" |
| 260 | + BODY="${MARKER} |
| 261 | + $(cat review_comment.md)" |
| 262 | +
|
| 263 | + # Find existing bot comment |
| 264 | + EXISTING_ID=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ |
| 265 | + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ |
| 266 | + | head -1) |
| 267 | +
|
| 268 | + if [ -n "$EXISTING_ID" ]; then |
| 269 | + # Update existing comment |
| 270 | + gh api "repos/${{ github.repository }}/issues/comments/${EXISTING_ID}" \ |
| 271 | + -X PATCH \ |
| 272 | + -f body="$BODY" |
| 273 | + echo "Updated existing comment ${EXISTING_ID}" |
| 274 | + else |
| 275 | + # Create new comment |
| 276 | + gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ |
| 277 | + -f body="$BODY" |
| 278 | + echo "Created new review comment" |
| 279 | + fi |
0 commit comments