Skip to content

Commit e84040b

Browse files
authored
allow reply-to-feedback in this repo (#92)
Signed-off-by: Derek Misler <derek.misler@docker.com>
1 parent 1556422 commit e84040b

File tree

2 files changed

+395
-246
lines changed

2 files changed

+395
-246
lines changed
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
# Handles AI replies to feedback on review comments.
2+
# Triggered by workflow_run so it always runs in the base repo context
3+
# with full permissions and secrets — even for fork PRs in public repos.
4+
#
5+
# The triggering workflow (Self PR Review → capture-feedback) uploads
6+
# a pr-review-feedback artifact containing:
7+
# feedback.json — the raw comment payload
8+
# metadata.json — PR number, repo, comment IDs, author, is_agent flag
9+
10+
name: Reply to Feedback
11+
12+
on:
13+
workflow_run:
14+
workflows: ["Self PR Review"]
15+
types: [completed]
16+
17+
permissions:
18+
contents: read
19+
pull-requests: write
20+
issues: write
21+
actions: read # Required to download artifacts from the triggering run
22+
23+
jobs:
24+
reply:
25+
# Only run if the triggering workflow succeeded (artifact was uploaded)
26+
if: github.event.workflow_run.conclusion == 'success'
27+
runs-on: ubuntu-latest
28+
env:
29+
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
30+
31+
steps:
32+
# ----------------------------------------------------------------
33+
# Download artifact from the triggering workflow run
34+
# ----------------------------------------------------------------
35+
- name: Download feedback artifact
36+
id: download
37+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
38+
with:
39+
script: |
40+
const runId = context.payload.workflow_run.id;
41+
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
42+
owner: context.repo.owner,
43+
repo: context.repo.repo,
44+
run_id: runId
45+
});
46+
47+
const match = artifacts.data.artifacts.find(a => a.name === 'pr-review-feedback');
48+
if (!match) {
49+
console.log('⏭️ No pr-review-feedback artifact found — not a feedback event');
50+
core.setOutput('found', 'false');
51+
return;
52+
}
53+
54+
const zip = await github.rest.actions.downloadArtifact({
55+
owner: context.repo.owner,
56+
repo: context.repo.repo,
57+
artifact_id: match.id,
58+
archive_format: 'zip'
59+
});
60+
61+
const fs = require('fs');
62+
fs.writeFileSync('/tmp/feedback.zip', Buffer.from(zip.data));
63+
core.setOutput('found', 'true');
64+
console.log('✅ Downloaded feedback artifact');
65+
66+
- name: Extract artifact
67+
if: steps.download.outputs.found == 'true'
68+
shell: bash
69+
run: |
70+
mkdir -p /tmp/feedback
71+
unzip -o /tmp/feedback.zip -d /tmp/feedback
72+
73+
# ----------------------------------------------------------------
74+
# Read metadata and decide whether to proceed
75+
# ----------------------------------------------------------------
76+
- name: Read metadata
77+
if: steps.download.outputs.found == 'true'
78+
id: meta
79+
shell: bash
80+
run: |
81+
if [ ! -f /tmp/feedback/metadata.json ]; then
82+
echo "⏭️ No metadata.json in artifact — legacy artifact, skipping"
83+
echo "proceed=false" >> $GITHUB_OUTPUT
84+
exit 0
85+
fi
86+
87+
# Extract fields from metadata
88+
PR_NUMBER=$(jq -r '.pr_number' /tmp/feedback/metadata.json)
89+
REPO=$(jq -r '.repo' /tmp/feedback/metadata.json)
90+
PARENT_COMMENT_ID=$(jq -r '.parent_comment_id' /tmp/feedback/metadata.json)
91+
COMMENT_ID=$(jq -r '.comment_id' /tmp/feedback/metadata.json)
92+
AUTHOR=$(jq -r '.author' /tmp/feedback/metadata.json)
93+
AUTHOR_TYPE=$(jq -r '.author_type' /tmp/feedback/metadata.json)
94+
IS_AGENT=$(jq -r '.is_agent_comment' /tmp/feedback/metadata.json)
95+
FILE_PATH=$(jq -r '.file_path' /tmp/feedback/metadata.json)
96+
LINE=$(jq -r '.line' /tmp/feedback/metadata.json)
97+
98+
# Validate PR number is numeric
99+
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || [ "$PR_NUMBER" -lt 1 ]; then
100+
echo "::error::Invalid PR number: $PR_NUMBER"
101+
echo "proceed=false" >> $GITHUB_OUTPUT
102+
exit 0
103+
fi
104+
105+
# Skip if not an agent comment or if commenter is a bot
106+
if [ "$IS_AGENT" != "true" ] || [ "$AUTHOR_TYPE" = "Bot" ]; then
107+
echo "⏭️ Not an agent comment reply or author is a bot — skipping"
108+
echo "proceed=false" >> $GITHUB_OUTPUT
109+
exit 0
110+
fi
111+
112+
echo "proceed=true" >> $GITHUB_OUTPUT
113+
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
114+
echo "repo=$REPO" >> $GITHUB_OUTPUT
115+
echo "parent_comment_id=$PARENT_COMMENT_ID" >> $GITHUB_OUTPUT
116+
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
117+
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
118+
echo "file_path=$FILE_PATH" >> $GITHUB_OUTPUT
119+
echo "line=$LINE" >> $GITHUB_OUTPUT
120+
121+
echo "✅ Metadata loaded: PR #$PR_NUMBER, comment $COMMENT_ID by @$AUTHOR"
122+
123+
# ----------------------------------------------------------------
124+
# Add 👀 reaction so the user knows their reply was received
125+
# ----------------------------------------------------------------
126+
- name: Add eyes reaction
127+
if: steps.meta.outputs.proceed == 'true'
128+
continue-on-error: true
129+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
130+
env:
131+
COMMENT_ID: ${{ steps.meta.outputs.comment_id }}
132+
REPO: ${{ steps.meta.outputs.repo }}
133+
with:
134+
script: |
135+
const [owner, repo] = process.env.REPO.split('/');
136+
await github.rest.reactions.createForPullRequestReviewComment({
137+
owner,
138+
repo,
139+
comment_id: parseInt(process.env.COMMENT_ID, 10),
140+
content: 'eyes'
141+
});
142+
console.log('👀 Added eyes reaction to triggering comment');
143+
144+
# ----------------------------------------------------------------
145+
# Authorization check via org membership
146+
# ----------------------------------------------------------------
147+
- name: Check authorization
148+
if: steps.meta.outputs.proceed == 'true'
149+
id: auth
150+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
151+
env:
152+
USERNAME: ${{ steps.meta.outputs.author }}
153+
with:
154+
github-token: ${{ secrets.CAGENT_ORG_MEMBERSHIP_TOKEN }}
155+
script: |
156+
const org = 'docker';
157+
const username = process.env.USERNAME;
158+
159+
try {
160+
await github.rest.orgs.checkMembershipForUser({ org, username });
161+
core.setOutput('authorized', 'true');
162+
console.log(`✅ ${username} is a ${org} org member — authorized`);
163+
} catch (error) {
164+
if (error.status === 404 || error.status === 302) {
165+
core.setOutput('authorized', 'false');
166+
console.log(`⏭️ ${username} is not a ${org} org member — not authorized`);
167+
} else if (error.status === 401) {
168+
core.warning('CAGENT_ORG_MEMBERSHIP_TOKEN secret is missing or invalid');
169+
core.setOutput('authorized', 'false');
170+
} else {
171+
core.warning(`Failed to check org membership: ${error.message}`);
172+
core.setOutput('authorized', 'false');
173+
}
174+
}
175+
176+
- name: Notify unauthorized user
177+
if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'false'
178+
continue-on-error: true
179+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
180+
env:
181+
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
182+
REPO: ${{ steps.meta.outputs.repo }}
183+
ROOT_COMMENT_ID: ${{ steps.meta.outputs.parent_comment_id }}
184+
AUTHOR: ${{ steps.meta.outputs.author }}
185+
with:
186+
script: |
187+
const [owner, repo] = process.env.REPO.split('/');
188+
const body = 'Sorry @' + process.env.AUTHOR + ', conversational replies are currently available to repository collaborators only. Your feedback has still been captured and will be used to improve future reviews.\n\n<!-- cagent-review-reply -->';
189+
await github.rest.pulls.createReplyForReviewComment({
190+
owner,
191+
repo,
192+
pull_number: parseInt(process.env.PR_NUMBER, 10),
193+
comment_id: parseInt(process.env.ROOT_COMMENT_ID, 10),
194+
body
195+
});
196+
197+
# ----------------------------------------------------------------
198+
# Build thread context from API data
199+
# ----------------------------------------------------------------
200+
- name: Build thread context
201+
if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true'
202+
id: thread
203+
shell: bash
204+
env:
205+
GH_TOKEN: ${{ github.token }}
206+
ROOT_ID: ${{ steps.meta.outputs.parent_comment_id }}
207+
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
208+
REPO: ${{ steps.meta.outputs.repo }}
209+
FILE_PATH: ${{ steps.meta.outputs.file_path }}
210+
LINE: ${{ steps.meta.outputs.line }}
211+
TRIGGER_COMMENT_ID: ${{ steps.meta.outputs.comment_id }}
212+
run: |
213+
# Read the triggering comment body and author from the saved artifact
214+
TRIGGER_COMMENT_BODY=$(jq -r '.body // ""' /tmp/feedback/feedback.json)
215+
TRIGGER_COMMENT_AUTHOR=$(jq -r '.user.login // ""' /tmp/feedback/feedback.json)
216+
217+
if [ -z "$TRIGGER_COMMENT_BODY" ]; then
218+
echo "::error::Triggering comment body is empty or missing"
219+
exit 1
220+
fi
221+
222+
# Fetch the root comment
223+
root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID") || {
224+
echo "::error::Failed to fetch root comment $ROOT_ID" >&2
225+
exit 1
226+
}
227+
root_body=$(echo "$root" | jq -r '.body // ""')
228+
229+
# Fetch all review comments on this PR and filter to this thread
230+
all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" | \
231+
jq -s --arg root_id "$ROOT_ID" \
232+
'[.[][] | select(.in_reply_to_id == ($root_id | tonumber))] | sort_by(.created_at)') || {
233+
echo "::error::Failed to fetch thread comments for PR $PR_NUMBER" >&2
234+
exit 1
235+
}
236+
237+
# Build the thread context
238+
DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)"
239+
240+
{
241+
echo "prompt<<$DELIM"
242+
echo "A developer replied to your review comment. Read the thread context below and respond"
243+
echo "in the same thread."
244+
echo ""
245+
echo "---"
246+
echo "REPO=$REPO"
247+
echo "PR_NUMBER=$PR_NUMBER"
248+
echo "ROOT_COMMENT_ID=$ROOT_ID"
249+
echo "FILE_PATH=$FILE_PATH"
250+
echo "LINE=$LINE"
251+
echo ""
252+
echo "[ORIGINAL REVIEW COMMENT]"
253+
echo "$root_body"
254+
echo ""
255+
256+
reply_count=$(echo "$all_comments" | jq 'length')
257+
if [ "$reply_count" -gt 0 ]; then
258+
for i in $(seq 0 $((reply_count - 1))); do
259+
comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue
260+
# Skip the triggering comment — we append it from the artifact below
261+
if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then
262+
continue
263+
fi
264+
user_type=$(echo "$all_comments" | jq -r ".[$i].user.type") || continue
265+
author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue
266+
body=$(echo "$all_comments" | jq -r ".[$i].body") || continue
267+
if [ "$user_type" = "Bot" ]; then
268+
echo "[YOUR PREVIOUS REPLY by @$author]"
269+
else
270+
echo "[REPLY by @$author]"
271+
fi
272+
echo "$body"
273+
echo ""
274+
done
275+
fi
276+
277+
echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to"
278+
echo "$TRIGGER_COMMENT_BODY"
279+
echo ""
280+
echo "$DELIM"
281+
} >> $GITHUB_OUTPUT
282+
283+
echo "✅ Built thread context with replies"
284+
285+
# ----------------------------------------------------------------
286+
# Checkout and run reply agent
287+
# ----------------------------------------------------------------
288+
- name: Checkout PR head
289+
if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true'
290+
id: checkout
291+
continue-on-error: true
292+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
293+
with:
294+
fetch-depth: 0
295+
ref: refs/pull/${{ steps.meta.outputs.pr_number }}/head
296+
297+
- name: Generate GitHub App token
298+
if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true'
299+
id: app-token
300+
continue-on-error: true
301+
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
302+
with:
303+
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
304+
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
305+
306+
- name: Run reply
307+
if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true' && steps.checkout.outcome == 'success' && steps.thread.outcome == 'success'
308+
id: run-reply
309+
continue-on-error: true
310+
uses: ./review-pr/reply
311+
with:
312+
thread-context: ${{ steps.thread.outputs.prompt }}
313+
comment-id: ${{ steps.meta.outputs.comment_id }}
314+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
315+
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
316+
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
317+
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
318+
xai-api-key: ${{ secrets.XAI_API_KEY }}
319+
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
320+
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
321+
github-token: ${{ steps.app-token.outputs.token || github.token }}
322+
323+
# ----------------------------------------------------------------
324+
# Failure handling
325+
# ----------------------------------------------------------------
326+
- name: React on failure
327+
if: >-
328+
always() &&
329+
steps.meta.outputs.proceed == 'true' &&
330+
steps.auth.outputs.authorized == 'true' &&
331+
(steps.checkout.outcome == 'failure' || steps.thread.outcome == 'failure' || steps.run-reply.outcome == 'failure')
332+
continue-on-error: true
333+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
334+
env:
335+
COMMENT_ID: ${{ steps.meta.outputs.comment_id }}
336+
REPO: ${{ steps.meta.outputs.repo }}
337+
with:
338+
github-token: ${{ steps.app-token.outputs.token || github.token }}
339+
script: |
340+
const [owner, repo] = process.env.REPO.split('/');
341+
await github.rest.reactions.createForPullRequestReviewComment({
342+
owner,
343+
repo,
344+
comment_id: parseInt(process.env.COMMENT_ID, 10),
345+
content: 'confused'
346+
});
347+
console.log('😕 Reply failed — added confused reaction');

0 commit comments

Comments
 (0)