Skip to content

Commit 5f810f8

Browse files
anakrishCopilot
andcommitted
feat: add multi-perspective review workflow using GitHub Models API
Adds a GitHub Actions workflow that: - Triggers on PRs via pull_request_target (fork-safe, API-only diff) - Selects relevant perspectives based on changed file paths - Calls GitHub Models API with agent instructions + file context - Requests structured JSON output, renders perspective-tagged markdown - Upserts a single consolidated review comment (idempotent) Security: never checks out PR head code; pins actions by SHA; uses concurrency groups to cancel stale runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cf49681 commit 5f810f8

1 file changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)