33# Licensed under the MIT License.
44#
55# Multi-perspective PR review using GitHub Models API.
6- # Called by the perspective- review.yml workflow .
6+ # Posts one PR review per perspective with inline code comments .
77#
88# Usage: perspective-review.sh <repo> <pr_number>
99# Requires: GITHUB_TOKEN env var, jq, gh CLI
@@ -15,19 +15,61 @@ PR_NUMBER="$2"
1515
1616echo " === Perspective Review for ${REPO} #${PR_NUMBER} ==="
1717
18- # Step 1: Get changed files
18+ # Step 1: Get PR metadata (head SHA) and changed files
19+ echo " Fetching PR metadata..."
20+ PR_DATA=$( gh api " repos/${REPO} /pulls/${PR_NUMBER} " )
21+ HEAD_SHA=$( echo " $PR_DATA " | jq -r ' .head.sha' )
22+ echo " Head SHA: ${HEAD_SHA} "
23+
1924echo " Fetching changed files..."
20- gh api " repos/${REPO} /pulls/${PR_NUMBER} /files" \
25+ gh api " repos/${REPO} /pulls/${PR_NUMBER} /files" --paginate \
2126 --jq ' .[].filename' > /tmp/changed_files.txt
2227echo " Changed files: $( wc -l < /tmp/changed_files.txt) "
2328
24- # Step 2: Get the diff (truncate to ~60KB for token limits)
29+ # Step 2: Get the diff and extract valid line anchors
2530echo " Fetching diff..."
2631gh api " repos/${REPO} /pulls/${PR_NUMBER} " \
2732 -H " Accept: application/vnd.github.v3.diff" \
2833 | head -c 60000 > /tmp/pr_diff.txt
2934echo " Diff size: $( wc -c < /tmp/pr_diff.txt) bytes"
3035
36+ # Parse diff to extract valid RIGHT-side line anchors with code content.
37+ # Format: file:line:type:code (type is "added" for + lines, "context" for unchanged)
38+ echo " Extracting valid line anchors from diff..."
39+ awk '
40+ /^diff --git/ { file = "" }
41+ /^\+\+\+ b\// { file = substr($0, 7) }
42+ /^@@ / {
43+ match($0, /\+([0-9]+)(,([0-9]+))?/, arr)
44+ start = arr[1] + 0
45+ line = start
46+ }
47+ file != "" && !/^diff --git/ && !/^---/ && !/^\+\+\+/ && !/^@@/ {
48+ if (/^-/) {
49+ # Deleted line: skip (not on RIGHT side)
50+ } else if (/^\+/) {
51+ # Added line
52+ code = substr($0, 2) # strip leading +
53+ gsub(/\t/, " ", code)
54+ print file ":" line ":added:" code
55+ line++
56+ } else {
57+ # Context line
58+ code = substr($0, 2) # strip leading space
59+ gsub(/\t/, " ", code)
60+ print file ":" line ":context:" code
61+ line++
62+ }
63+ }
64+ ' /tmp/pr_diff.txt > /tmp/valid_anchors_full.txt
65+
66+ # Also create a plain file:line list for validation
67+ awk -F: ' {print $1 ":" $2}' /tmp/valid_anchors_full.txt > /tmp/valid_anchors.txt
68+
69+ ADDED_COUNT=$( grep -c ' :added:' /tmp/valid_anchors_full.txt || echo 0)
70+ CONTEXT_COUNT=$( grep -c ' :context:' /tmp/valid_anchors_full.txt || echo 0)
71+ echo " Valid anchors: ${ADDED_COUNT} added, ${CONTEXT_COUNT} context"
72+
3173# Step 3: Select perspectives based on changed paths
3274PERSPECTIVES=" reliability-engineer,test-engineer"
3375
5193PERSPECTIVES=$( echo " $PERSPECTIVES " | tr ' ,' ' \n' | sort -u | tr ' \n' ' ,' | sed ' s/,$//' )
5294echo " Selected perspectives: ${PERSPECTIVES} "
5395
54- # Step 4: Build context
96+ # Step 4: Build context from knowledge files
5597echo " Building context..."
5698KNOWLEDGE_CTX=" "
5799for f in builtin-system value-semantics policy-evaluation-security ffi-boundary; do
66108
67109DIFF_CONTENT=$( cat /tmp/pr_diff.txt)
68110
69- # Step 5: Run each perspective
70- ALL_FINDINGS=" "
111+ # Build a structured anchor table for the prompt.
112+ # Show added lines prominently, include some context lines for reference.
113+ ANCHOR_TABLE=$( awk -F: '
114+ {
115+ file = $1; line = $2; type = $3
116+ # Rejoin remaining fields as code (code may contain colons)
117+ code = ""
118+ for (i = 4; i <= NF; i++) {
119+ if (i > 4) code = code ":"
120+ code = code $i
121+ }
122+ if (type == "added") {
123+ printf " + %s:%s %s\n", file, line, code
124+ }
125+ }
126+ ' /tmp/valid_anchors_full.txt)
127+
128+ # Also list context lines but more compactly (just file:line ranges)
129+ CONTEXT_SUMMARY=$( awk -F: '
130+ $3 == "context" { print $1 ":" $2 }
131+ ' /tmp/valid_anchors_full.txt | head -50)
132+
133+ # Plain file:line list for validation
134+ VALID_ANCHORS_PLAIN=$( cat /tmp/valid_anchors.txt)
135+
136+ # Step 5: Review with each perspective, posting one PR review per perspective
137+ TOTAL_FINDINGS=0
71138
72139for perspective in $( echo " $PERSPECTIVES " | tr ' ,' ' ' ) ; do
73140 echo " --- Reviewing: ${perspective} ---"
@@ -79,11 +146,21 @@ for perspective in $(echo "$PERSPECTIVES" | tr ',' ' '); do
79146 AGENT_INSTRUCTIONS=$( head -c 4000 " $agent_file " )
80147 fi
81148
82- # Format display name
149+ # Format display name and emoji
83150 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
151+ case " $perspective " in
152+ red-teamer) EMOJI=" 🔴" ;;
153+ security-auditor) EMOJI=" 🔒" ;;
154+ reliability-engineer) EMOJI=" ⚙️" ;;
155+ test-engineer) EMOJI=" 🧪" ;;
156+ semantics-expert) EMOJI=" 📐" ;;
157+ performance-engineer) EMOJI=" ⚡" ;;
158+ architect) EMOJI=" 🏗️" ;;
159+ api-steward) EMOJI=" 📡" ;;
160+ * ) EMOJI=" 🔍" ;;
161+ esac
162+
163+ cat > /tmp/review_prompt.txt << PROMPT
87164You are reviewing a pull request from the perspective of a ${DISPLAY_NAME} .
88165
89166Your agent instructions:
@@ -95,21 +172,36 @@ ${KNOWLEDGE_CTX}
95172PR Diff:
96173${DIFF_CONTENT}
97174
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
175+ === ANCHORING INSTRUCTIONS ===
176+
177+ Each finding MUST be anchored to a specific line in the diff.
178+ Below are the ADDED lines (marked with +) that you can reference.
179+ Pick the most relevant added line for each finding.
180+
181+ ADDED LINES (preferred — use these):
182+ ${ANCHOR_TABLE}
183+
184+ CONTEXT LINES (also valid, but prefer added lines above):
185+ ${CONTEXT_SUMMARY}
186+
187+ For each finding, set "file" and "line" to an EXACT file:line pair from the lists above.
188+ Do NOT invent line numbers. Do NOT use line numbers that aren't listed.
104189
105- If you find no issues from this perspective, return an empty array: []
190+ === OUTPUT FORMAT ===
106191
107- Return ONLY valid JSON — no markdown fences, no extra text.
108- EOF
192+ Respond with a JSON array. Each finding:
193+ - "severity": "critical" | "important" | "suggestion"
194+ - "title": one-sentence heading
195+ - "file": exact file path from the anchor lists
196+ - "line": exact line number from the anchor lists
197+ - "body": 2-4 sentence explanation in markdown
198+
199+ If no issues found, return: []
200+ Return ONLY valid JSON — no markdown fences, no commentary.
201+ PROMPT
109202
110203 PROMPT_CONTENT=$( cat /tmp/review_prompt.txt)
111204
112- # Call GitHub Models API using jq for safe JSON encoding
113205 RESPONSE=$( jq -n \
114206 --arg model " openai/gpt-4o-mini" \
115207 --arg prompt " $PROMPT_CONTENT " \
125217 -H " Content-Type: application/json" \
126218 -d @- 2> /dev/null || echo ' {"error": "API call failed"}' )
127219
128- # Extract content
129220 CONTENT=$( echo " $RESPONSE " | jq -r ' .choices[0].message.content // empty' 2> /dev/null || true)
130221
131222 if [ -z " $CONTENT " ]; then
@@ -134,74 +225,133 @@ EOF
134225 continue
135226 fi
136227
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
228+ # Strip markdown fences if the model wrapped them
229+ CONTENT=$( echo " $CONTENT " | sed ' s/^```json//; s/^```//; /^$/d' )
167230
168- Automated multi-perspective review of this PR.
169- Each finding is tagged with the perspective that identified it.
231+ # Validate JSON
232+ if ! echo " $CONTENT " | jq empty 2> /dev/null; then
233+ echo " Invalid JSON response, skipping"
234+ continue
235+ fi
170236
171- ---
237+ FINDING_COUNT= $( echo " $CONTENT " | jq ' if type == "array" then length else 0 end ' 2> /dev/null || echo 0 )
172238
173- $( echo -e " $ALL_FINDINGS " )
239+ if [ " $FINDING_COUNT " -eq 0 ]; then
240+ echo " No findings"
241+ continue
242+ fi
174243
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
244+ echo " Found ${FINDING_COUNT} findings"
245+ TOTAL_FINDINGS=$(( TOTAL_FINDINGS + FINDING_COUNT))
246+
247+ # Separate findings into anchored (inline) and unanchored (body-only)
248+ # Validate each finding's file:line against the valid anchors list
249+ INLINE_COMMENTS=$( echo " $CONTENT " | jq -c --arg anchors " $VALID_ANCHORS_PLAIN " '
250+ ($anchors | split("\n") | map(select(. != ""))) as $valid |
251+ [.[] | select(.file != null and .line != null) |
252+ select((.file + ":" + (.line | tostring)) as $key | $valid | any(. == $key))]
253+ ' 2> /dev/null || echo " []" )
254+
255+ UNANCHORED=$( echo " $CONTENT " | jq -c --arg anchors " $VALID_ANCHORS_PLAIN " '
256+ ($anchors | split("\n") | map(select(. != ""))) as $valid |
257+ [.[] | select(
258+ .file == null or .line == null or
259+ ((.file + ":" + (.line | tostring)) as $key | $valid | all(. != $key))
260+ )]
261+ ' 2> /dev/null || echo " []" )
262+
263+ INLINE_COUNT=$( echo " $INLINE_COMMENTS " | jq ' length' 2> /dev/null || echo 0)
264+ UNANCHORED_COUNT=$( echo " $UNANCHORED " | jq ' length' 2> /dev/null || echo 0)
265+ echo " Inline: ${INLINE_COUNT} , Unanchored: ${UNANCHORED_COUNT} "
266+
267+ # Build review body
268+ SEVERITY_ICON () {
269+ case " $1 " in
270+ critical) echo " 🔴" ;;
271+ important) echo " 🟠" ;;
272+ suggestion) echo " 🔵" ;;
273+ * ) echo " ⚪" ;;
274+ esac
275+ }
276+
277+ REVIEW_BODY=" ${EMOJI} **${DISPLAY_NAME} ** — ${FINDING_COUNT} finding(s)"
278+
279+ # Add unanchored findings to the review body
280+ if [ " $UNANCHORED_COUNT " -gt 0 ]; then
281+ UNANCHORED_TEXT=$( echo " $UNANCHORED " | jq -r '
282+ .[] |
283+ "\n\n" +
284+ (if .severity == "critical" then "🔴" elif .severity == "important" then "🟠" else "🔵" end) +
285+ " **" + .severity + "**: " + .title +
286+ "\n" + .body +
287+ (if .file then "\n📁 `" + .file + "`" + (if .line then ":" + (.line | tostring) else "" end) else "" end)
288+ ' 2> /dev/null || true)
289+ REVIEW_BODY=" ${REVIEW_BODY}
290+
291+ ### General findings
292+ ${UNANCHORED_TEXT} "
293+ fi
181294
182- ✅ No significant findings from the selected perspectives.
295+ # Build inline comments JSON for the PR Review API
296+ COMMENTS_JSON=" []"
297+ if [ " $INLINE_COUNT " -gt 0 ]; then
298+ COMMENTS_JSON=$( echo " $INLINE_COMMENTS " | jq -c --arg perspective " $DISPLAY_NAME " '
299+ [.[] | {
300+ path: .file,
301+ line: .line,
302+ side: "RIGHT",
303+ body: (
304+ "**" +
305+ (if .severity == "critical" then "🔴 Critical" elif .severity == "important" then "🟠 Important" else "🔵 Suggestion" end) +
306+ "**: " + .title + "\n\n" + .body
307+ )
308+ }]
309+ ' 2> /dev/null || echo " []" )
310+ fi
183311
184- <sub>Generated by perspective-review workflow • Perspectives: ${PERSPECTIVES} </sub>
185- EOF
186- fi
312+ # Post the PR review
313+ echo " Posting review..."
314+ REVIEW_PAYLOAD=$( jq -n \
315+ --arg sha " $HEAD_SHA " \
316+ --arg body " $REVIEW_BODY " \
317+ --argjson comments " $COMMENTS_JSON " \
318+ ' {
319+ commit_id: $sha,
320+ body: $body,
321+ event: "COMMENT",
322+ comments: $comments
323+ }' )
324+
325+ REVIEW_RESULT=$( echo " $REVIEW_PAYLOAD " | gh api " repos/${REPO} /pulls/${PR_NUMBER} /reviews" \
326+ --input - 2>&1 || true)
327+
328+ if echo " $REVIEW_RESULT " | jq -e ' .id' > /dev/null 2>&1 ; then
329+ REVIEW_ID=$( echo " $REVIEW_RESULT " | jq -r ' .id' )
330+ echo " Posted review ${REVIEW_ID} "
331+ else
332+ # If inline comments failed (invalid anchors), retry without them
333+ echo " Review with inline comments failed, retrying as body-only..."
334+ REVIEW_BODY=" ${REVIEW_BODY}
187335
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
336+ ### Findings"
337+ BODY_FINDINGS=$( echo " $CONTENT " | jq -r '
338+ .[] |
339+ "\n" +
340+ (if .severity == "critical" then "🔴" elif .severity == "important" then "🟠" else "🔵" end) +
341+ " **" + .severity + "**: " + .title +
342+ "\n" + .body +
343+ (if .file then "\n📁 `" + .file + "`" + (if .line then ":" + (.line | tostring) else "" end) else "" end)
344+ ' 2> /dev/null || true)
345+ REVIEW_BODY=" ${REVIEW_BODY}${BODY_FINDINGS} "
346+
347+ jq -n \
348+ --arg sha " $HEAD_SHA " \
349+ --arg body " $REVIEW_BODY " \
350+ ' {commit_id: $sha, body: $body, event: "COMMENT", comments: []}' \
351+ | gh api " repos/${REPO} /pulls/${PR_NUMBER} /reviews" --input - > /dev/null 2>&1 \
352+ && echo " Posted body-only review" \
353+ || echo " Failed to post review"
354+ fi
355+ done
206356
207- echo " === Review complete ==="
357+ echo " === Review complete: ${TOTAL_FINDINGS} total findings ==="
0 commit comments