Skip to content

Commit 00e53aa

Browse files
anakrishCopilot
andcommitted
feat: use PR Review API for inline code comments
Switch from issue comments to the PR Review API so findings appear as inline comments on specific diff lines with code snippets. One review per perspective. Key changes: - Parse diff to extract valid RIGHT-side line anchors - Give LLM the valid anchors list to choose from - Two-bucket model: inline for anchored, body for unanchored - Lock reviews to the analyzed commit SHA - Fallback to body-only review if inline comments fail Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dc5f5dc commit 00e53aa

1 file changed

Lines changed: 195 additions & 84 deletions

File tree

.github/scripts/perspective-review.sh

Lines changed: 195 additions & 84 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,56 @@ 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 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
3269
PERSPECTIVES="reliability-engineer,test-engineer"
3370

@@ -51,7 +88,7 @@ fi
5188
PERSPECTIVES=$(echo "$PERSPECTIVES" | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//')
5289
echo "Selected perspectives: ${PERSPECTIVES}"
5390

54-
# Step 4: Build context
91+
# Step 4: Build context from knowledge files
5592
echo "Building context..."
5693
KNOWLEDGE_CTX=""
5794
for f in builtin-system value-semantics policy-evaluation-security ffi-boundary; do
@@ -66,8 +103,11 @@ done
66103

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

72112
for 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
87137
You are reviewing a pull request from the perspective of a ${DISPLAY_NAME}.
88138
89139
Your agent instructions:
@@ -95,21 +145,24 @@ ${KNOWLEDGE_CTX}
95145
PR 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: []
107161
Return 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" \
@@ -125,7 +178,6 @@ EOF
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

Comments
 (0)