Skip to content

Commit b0173f5

Browse files
anakrishCopilot
andcommitted
feat: perspective review with inline PR comments
Redesigned the perspective-review script to use the PR Review API for inline code comments instead of issue comments. Each perspective posts a separate review with findings anchored to specific diff lines. Key improvements: - Uses PR Review API for inline comments with code snippets - One review per perspective for clear attribution - Parses diff to extract valid line anchors with code content - LLM picks from exact anchor list (100% inline rate) - Two-bucket fallback: inline for anchored, body for unanchored - Locks reviews to analyzed commit SHA Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 54f3663 commit b0173f5

1 file changed

Lines changed: 235 additions & 85 deletions

File tree

.github/scripts/perspective-review.sh

Lines changed: 235 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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

1616
echo "=== 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+
1924
echo "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
2227
echo "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
2530
echo "Fetching diff..."
2631
gh api "repos/${REPO}/pulls/${PR_NUMBER}" \
2732
-H "Accept: application/vnd.github.v3.diff" \
2833
| head -c 60000 > /tmp/pr_diff.txt
2934
echo "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
3274
PERSPECTIVES="reliability-engineer,test-engineer"
3375

@@ -51,7 +93,7 @@ fi
5193
PERSPECTIVES=$(echo "$PERSPECTIVES" | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//')
5294
echo "Selected perspectives: ${PERSPECTIVES}"
5395

54-
# Step 4: Build context
96+
# Step 4: Build context from knowledge files
5597
echo "Building context..."
5698
KNOWLEDGE_CTX=""
5799
for f in builtin-system value-semantics policy-evaluation-security ffi-boundary; do
@@ -66,8 +108,33 @@ done
66108

67109
DIFF_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

72139
for 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
87164
You are reviewing a pull request from the perspective of a ${DISPLAY_NAME}.
88165
89166
Your agent instructions:
@@ -95,21 +172,36 @@ ${KNOWLEDGE_CTX}
95172
PR 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" \
@@ -125,7 +217,6 @@ EOF
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

Comments
 (0)