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,56 @@ 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 numbers per file.
37+ # These are the only lines the PR Review API will accept for inline comments.
38+ echo " Extracting valid line anchors from diff..."
39+ awk '
40+ /^diff --git/ {
41+ # Extract filename from +++ line (next after ---)
42+ file = ""
43+ }
44+ /^\+\+\+ b\// {
45+ file = substr($0, 7) # strip "+++ b/"
46+ }
47+ /^@@ / {
48+ # Parse new-file line number from @@ -old,len +new,len @@
49+ match($0, /\+([0-9]+)(,([0-9]+))?/, arr)
50+ start = arr[1] + 0
51+ count = (arr[3] != "") ? arr[3] + 0 : 1
52+ line = start
53+ }
54+ file != "" && !/^diff --git/ && !/^---/ && !/^\+\+\+/ && !/^@@/ {
55+ if (/^-/) {
56+ # Deleted line: not on RIGHT side, skip
57+ } else {
58+ # Added (+) or context ( ) line: valid on RIGHT side
59+ if (file != "" && line > 0) {
60+ print file ":" line
61+ }
62+ line++
63+ }
64+ }
65+ ' /tmp/pr_diff.txt > /tmp/valid_anchors.txt
66+ echo " Valid anchors: $( wc -l < /tmp/valid_anchors.txt) "
67+
3168# Step 3: Select perspectives based on changed paths
3269PERSPECTIVES=" reliability-engineer,test-engineer"
3370
5188PERSPECTIVES=$( echo " $PERSPECTIVES " | tr ' ,' ' \n' | sort -u | tr ' \n' ' ,' | sed ' s/,$//' )
5289echo " Selected perspectives: ${PERSPECTIVES} "
5390
54- # Step 4: Build context
91+ # Step 4: Build context from knowledge files
5592echo " Building context..."
5693KNOWLEDGE_CTX=" "
5794for f in builtin-system value-semantics policy-evaluation-security ffi-boundary; do
66103
67104DIFF_CONTENT=$( cat /tmp/pr_diff.txt)
68105
69- # Step 5: Run each perspective
70- ALL_FINDINGS=" "
106+ # Build the anchor list for the prompt (file:line pairs the LLM can reference)
107+ ANCHOR_LIST=$( cat /tmp/valid_anchors.txt)
108+
109+ # Step 5: Review with each perspective, posting one PR review per perspective
110+ TOTAL_FINDINGS=0
71111
72112for perspective in $( echo " $PERSPECTIVES " | tr ' ,' ' ' ) ; do
73113 echo " --- Reviewing: ${perspective} ---"
@@ -79,11 +119,21 @@ for perspective in $(echo "$PERSPECTIVES" | tr ',' ' '); do
79119 AGENT_INSTRUCTIONS=$( head -c 4000 " $agent_file " )
80120 fi
81121
82- # Format display name
122+ # Format display name and emoji
83123 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
124+ case " $perspective " in
125+ red-teamer) EMOJI=" 🔴" ;;
126+ security-auditor) EMOJI=" 🔒" ;;
127+ reliability-engineer) EMOJI=" ⚙️" ;;
128+ test-engineer) EMOJI=" 🧪" ;;
129+ semantics-expert) EMOJI=" 📐" ;;
130+ performance-engineer) EMOJI=" ⚡" ;;
131+ architect) EMOJI=" 🏗️" ;;
132+ api-steward) EMOJI=" 📡" ;;
133+ * ) EMOJI=" 🔍" ;;
134+ esac
135+
136+ cat > /tmp/review_prompt.txt << PROMPT
87137You are reviewing a pull request from the perspective of a ${DISPLAY_NAME} .
88138
89139Your agent instructions:
@@ -95,21 +145,24 @@ ${KNOWLEDGE_CTX}
95145PR Diff:
96146${DIFF_CONTENT}
97147
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
148+ IMPORTANT: You must anchor findings to exact lines from this list of valid diff lines.
149+ Each entry is file:line. Only use lines from this list:
150+
151+ ${ANCHOR_LIST}
104152
105- If you find no issues from this perspective, return an empty array: []
153+ Respond with a JSON array of findings. Each finding must have:
154+ - "severity": one of "critical", "important", "suggestion"
155+ - "title": a single sentence suitable as a heading
156+ - "file": exact file path from the valid lines list above
157+ - "line": exact line number from the valid lines list above, or null if no suitable anchor
158+ - "body": 2-4 sentences explaining the issue in markdown
106159
160+ If you find no issues, return an empty array: []
107161Return ONLY valid JSON — no markdown fences, no extra text.
108- EOF
162+ PROMPT
109163
110164 PROMPT_CONTENT=$( cat /tmp/review_prompt.txt)
111165
112- # Call GitHub Models API using jq for safe JSON encoding
113166 RESPONSE=$( jq -n \
114167 --arg model " openai/gpt-4o-mini" \
115168 --arg prompt " $PROMPT_CONTENT " \
125178 -H " Content-Type: application/json" \
126179 -d @- 2> /dev/null || echo ' {"error": "API call failed"}' )
127180
128- # Extract content
129181 CONTENT=$( echo " $RESPONSE " | jq -r ' .choices[0].message.content // empty' 2> /dev/null || true)
130182
131183 if [ -z " $CONTENT " ]; then
@@ -134,74 +186,133 @@ EOF
134186 continue
135187 fi
136188
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
189+ # Strip markdown fences if the model wrapped them
190+ CONTENT=$( echo " $CONTENT " | sed ' s/^```json//; s/^```//; /^$/d' )
159191
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.
192+ # Validate JSON
193+ if ! echo " $CONTENT " | jq empty 2> /dev/null; then
194+ echo " Invalid JSON response, skipping"
195+ continue
196+ fi
170197
171- ---
198+ FINDING_COUNT= $( echo " $CONTENT " | jq ' if type == "array" then length else 0 end ' 2> /dev/null || echo 0 )
172199
173- $( echo -e " $ALL_FINDINGS " )
200+ if [ " $FINDING_COUNT " -eq 0 ]; then
201+ echo " No findings"
202+ continue
203+ fi
174204
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
205+ echo " Found ${FINDING_COUNT} findings"
206+ TOTAL_FINDINGS=$(( TOTAL_FINDINGS + FINDING_COUNT))
207+
208+ # Separate findings into anchored (inline) and unanchored (body-only)
209+ # Validate each finding's file:line against the valid anchors list
210+ INLINE_COMMENTS=$( echo " $CONTENT " | jq -c --arg anchors " $ANCHOR_LIST " '
211+ ($anchors | split("\n") | map(select(. != ""))) as $valid |
212+ [.[] | select(.file != null and .line != null) |
213+ select((.file + ":" + (.line | tostring)) as $key | $valid | any(. == $key))]
214+ ' 2> /dev/null || echo " []" )
215+
216+ UNANCHORED=$( echo " $CONTENT " | jq -c --arg anchors " $ANCHOR_LIST " '
217+ ($anchors | split("\n") | map(select(. != ""))) as $valid |
218+ [.[] | select(
219+ .file == null or .line == null or
220+ ((.file + ":" + (.line | tostring)) as $key | $valid | all(. != $key))
221+ )]
222+ ' 2> /dev/null || echo " []" )
223+
224+ INLINE_COUNT=$( echo " $INLINE_COMMENTS " | jq ' length' 2> /dev/null || echo 0)
225+ UNANCHORED_COUNT=$( echo " $UNANCHORED " | jq ' length' 2> /dev/null || echo 0)
226+ echo " Inline: ${INLINE_COUNT} , Unanchored: ${UNANCHORED_COUNT} "
227+
228+ # Build review body
229+ SEVERITY_ICON () {
230+ case " $1 " in
231+ critical) echo " 🔴" ;;
232+ important) echo " 🟠" ;;
233+ suggestion) echo " 🔵" ;;
234+ * ) echo " ⚪" ;;
235+ esac
236+ }
237+
238+ REVIEW_BODY=" ${EMOJI} **${DISPLAY_NAME} ** — ${FINDING_COUNT} finding(s)"
239+
240+ # Add unanchored findings to the review body
241+ if [ " $UNANCHORED_COUNT " -gt 0 ]; then
242+ UNANCHORED_TEXT=$( echo " $UNANCHORED " | jq -r '
243+ .[] |
244+ "\n\n" +
245+ (if .severity == "critical" then "🔴" elif .severity == "important" then "🟠" else "🔵" end) +
246+ " **" + .severity + "**: " + .title +
247+ "\n" + .body +
248+ (if .file then "\n📁 `" + .file + "`" + (if .line then ":" + (.line | tostring) else "" end) else "" end)
249+ ' 2> /dev/null || true)
250+ REVIEW_BODY=" ${REVIEW_BODY}
251+
252+ ### General findings
253+ ${UNANCHORED_TEXT} "
254+ fi
181255
182- ✅ No significant findings from the selected perspectives.
256+ # Build inline comments JSON for the PR Review API
257+ COMMENTS_JSON=" []"
258+ if [ " $INLINE_COUNT " -gt 0 ]; then
259+ COMMENTS_JSON=$( echo " $INLINE_COMMENTS " | jq -c --arg perspective " $DISPLAY_NAME " '
260+ [.[] | {
261+ path: .file,
262+ line: .line,
263+ side: "RIGHT",
264+ body: (
265+ "**" +
266+ (if .severity == "critical" then "🔴 Critical" elif .severity == "important" then "🟠 Important" else "🔵 Suggestion" end) +
267+ "**: " + .title + "\n\n" + .body
268+ )
269+ }]
270+ ' 2> /dev/null || echo " []" )
271+ fi
183272
184- <sub>Generated by perspective-review workflow • Perspectives: ${PERSPECTIVES} </sub>
185- EOF
186- fi
273+ # Post the PR review
274+ echo " Posting review..."
275+ REVIEW_PAYLOAD=$( jq -n \
276+ --arg sha " $HEAD_SHA " \
277+ --arg body " $REVIEW_BODY " \
278+ --argjson comments " $COMMENTS_JSON " \
279+ ' {
280+ commit_id: $sha,
281+ body: $body,
282+ event: "COMMENT",
283+ comments: $comments
284+ }' )
285+
286+ REVIEW_RESULT=$( echo " $REVIEW_PAYLOAD " | gh api " repos/${REPO} /pulls/${PR_NUMBER} /reviews" \
287+ --input - 2>&1 || true)
288+
289+ if echo " $REVIEW_RESULT " | jq -e ' .id' > /dev/null 2>&1 ; then
290+ REVIEW_ID=$( echo " $REVIEW_RESULT " | jq -r ' .id' )
291+ echo " Posted review ${REVIEW_ID} "
292+ else
293+ # If inline comments failed (invalid anchors), retry without them
294+ echo " Review with inline comments failed, retrying as body-only..."
295+ REVIEW_BODY=" ${REVIEW_BODY}
187296
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
297+ ### Findings"
298+ BODY_FINDINGS=$( echo " $CONTENT " | jq -r '
299+ .[] |
300+ "\n" +
301+ (if .severity == "critical" then "🔴" elif .severity == "important" then "🟠" else "🔵" end) +
302+ " **" + .severity + "**: " + .title +
303+ "\n" + .body +
304+ (if .file then "\n📁 `" + .file + "`" + (if .line then ":" + (.line | tostring) else "" end) else "" end)
305+ ' 2> /dev/null || true)
306+ REVIEW_BODY=" ${REVIEW_BODY}${BODY_FINDINGS} "
307+
308+ jq -n \
309+ --arg sha " $HEAD_SHA " \
310+ --arg body " $REVIEW_BODY " \
311+ ' {commit_id: $sha, body: $body, event: "COMMENT", comments: []}' \
312+ | gh api " repos/${REPO} /pulls/${PR_NUMBER} /reviews" --input - > /dev/null 2>&1 \
313+ && echo " Posted body-only review" \
314+ || echo " Failed to post review"
315+ fi
316+ done
206317
207- echo " === Review complete ==="
318+ echo " === Review complete: ${TOTAL_FINDINGS} total findings ==="
0 commit comments