Skip to content

Commit 1e50cd9

Browse files
committed
Add conversational replies to PR review feedback
Signed-off-by: Derek Misler <derek.misler@docker.com>
1 parent 99bfcb3 commit 1e50cd9

File tree

5 files changed

+589
-6
lines changed

5 files changed

+589
-6
lines changed

.github/workflows/review-pr.yml

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,187 @@ jobs:
291291
name: pr-review-feedback
292292
path: feedback/
293293
retention-days: 90
294+
295+
# ==========================================================================
296+
# REPLY TO FEEDBACK
297+
# Responds directly in the PR review thread when a user replies to an agent
298+
# comment. Runs in parallel with capture-feedback — this job handles the
299+
# synchronous conversational reply, while capture-feedback saves the artifact
300+
# for async learning on the next review run (resilient fallback).
301+
# ==========================================================================
302+
reply-to-feedback:
303+
if: |
304+
github.event_name == 'pull_request_review_comment' &&
305+
github.event.comment.in_reply_to_id &&
306+
github.event.comment.user.type != 'Bot'
307+
runs-on: ubuntu-latest
308+
env:
309+
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
310+
311+
steps:
312+
- name: Check if reply is to agent comment
313+
id: check
314+
shell: bash
315+
env:
316+
GH_TOKEN: ${{ github.token }}
317+
PARENT_ID: ${{ github.event.comment.in_reply_to_id }}
318+
REPO: ${{ github.repository }}
319+
run: |
320+
if [ -z "$PARENT_ID" ]; then
321+
echo "is_agent=false" >> $GITHUB_OUTPUT
322+
echo "⏭️ Not a reply comment, skipping"
323+
exit 0
324+
fi
325+
326+
parent=$(gh api "repos/$REPO/pulls/comments/$PARENT_ID" 2>/dev/null || echo "{}")
327+
body=$(echo "$parent" | jq -r '.body // ""')
328+
parent_user_type=$(echo "$parent" | jq -r '.user.type // ""')
329+
330+
# Defense-in-depth: verify the root comment was posted by a Bot (agent) AND
331+
# contains the review marker but NOT the reply marker (substring overlap).
332+
# The user.type check prevents matching human comments that happen to contain
333+
# the marker text (e.g., in discussions about the review system).
334+
if [ "$parent_user_type" = "Bot" ] && \
335+
echo "$body" | grep -q "<!-- cagent-review -->" && \
336+
! echo "$body" | grep -q "<!-- cagent-review-reply -->"; then
337+
echo "is_agent=true" >> $GITHUB_OUTPUT
338+
echo "root_comment_id=$PARENT_ID" >> $GITHUB_OUTPUT
339+
340+
# Extract file path and line from the root comment for context
341+
echo "file_path=$(echo "$parent" | jq -r '.path // ""')" >> $GITHUB_OUTPUT
342+
echo "line=$(echo "$parent" | jq -r '.line // .original_line // ""')" >> $GITHUB_OUTPUT
343+
echo "✅ Reply is to an agent review comment"
344+
else
345+
echo "is_agent=false" >> $GITHUB_OUTPUT
346+
echo "⏭️ Not a reply to agent comment, skipping"
347+
fi
348+
349+
- name: Check authorization
350+
if: steps.check.outputs.is_agent == 'true'
351+
id: auth
352+
shell: bash
353+
env:
354+
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
355+
run: |
356+
case "$AUTHOR_ASSOCIATION" in
357+
OWNER|MEMBER|COLLABORATOR)
358+
echo "authorized=true" >> $GITHUB_OUTPUT
359+
echo "✅ Author is $AUTHOR_ASSOCIATION — authorized to trigger reply"
360+
;;
361+
*)
362+
echo "authorized=false" >> $GITHUB_OUTPUT
363+
echo "⏭️ Author is $AUTHOR_ASSOCIATION — not authorized for reply"
364+
;;
365+
esac
366+
367+
- name: Build thread context
368+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
369+
id: thread
370+
shell: bash
371+
env:
372+
GH_TOKEN: ${{ github.token }}
373+
ROOT_ID: ${{ steps.check.outputs.root_comment_id }}
374+
PR_NUMBER: ${{ github.event.pull_request.number }}
375+
REPO: ${{ github.repository }}
376+
FILE_PATH: ${{ steps.check.outputs.file_path }}
377+
LINE: ${{ steps.check.outputs.line }}
378+
# The triggering comment from the webhook payload — guaranteed fresh,
379+
# unlike the API which may have eventual consistency lag.
380+
TRIGGER_COMMENT_BODY: ${{ github.event.comment.body }}
381+
TRIGGER_COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
382+
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
383+
run: |
384+
# Fetch the root comment
385+
root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID")
386+
root_body=$(echo "$root" | jq -r '.body // ""')
387+
388+
# Fetch all review comments on this PR and filter to this thread.
389+
# Uses --paginate to handle PRs with >100 review comments.
390+
# Each page is processed by jq independently, then merged with jq -s.
391+
# Note: the triggering comment may not appear here due to eventual
392+
# consistency, so we append it from the webhook payload below.
393+
all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" \
394+
--jq "[.[] | select(.in_reply_to_id == $ROOT_ID)]" | jq -s 'add // [] | sort_by(.created_at)')
395+
396+
# Build the thread context and save as step output.
397+
# Use a randomized delimiter to prevent comment body content from
398+
# colliding with the GITHUB_OUTPUT heredoc terminator.
399+
DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)"
400+
401+
{
402+
echo "prompt<<$DELIM"
403+
echo "A developer replied to your review comment. Read the thread context below and respond"
404+
echo "in the same thread."
405+
echo ""
406+
echo "---"
407+
echo "REPO=$REPO"
408+
echo "PR_NUMBER=$PR_NUMBER"
409+
echo "ROOT_COMMENT_ID=$ROOT_ID"
410+
echo "FILE_PATH=$FILE_PATH"
411+
echo "LINE=$LINE"
412+
echo ""
413+
echo "[ORIGINAL REVIEW COMMENT]"
414+
echo "$root_body"
415+
echo ""
416+
417+
# Add earlier replies from the API (excludes the triggering comment
418+
# to avoid duplication if the API already has it)
419+
reply_count=$(echo "$all_comments" | jq 'length')
420+
if [ "$reply_count" -gt 0 ]; then
421+
for i in $(seq 0 $((reply_count - 1))); do
422+
comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue
423+
# Skip the triggering comment — we append it from the payload below
424+
if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then
425+
continue
426+
fi
427+
author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue
428+
body=$(echo "$all_comments" | jq -r ".[$i].body") || continue
429+
echo "[REPLY by @$author]"
430+
echo "$body"
431+
echo ""
432+
done
433+
fi
434+
435+
# Always append the triggering comment last — sourced directly from
436+
# the webhook payload so it's guaranteed to be present.
437+
echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to"
438+
echo "$TRIGGER_COMMENT_BODY"
439+
echo ""
440+
echo "$DELIM"
441+
} >> $GITHUB_OUTPUT
442+
443+
echo "✅ Built thread context with replies (triggering comment from webhook payload)"
444+
445+
# Safe to checkout PR head because the reply agent only READS files (no code execution)
446+
- name: Checkout PR head
447+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
448+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
449+
with:
450+
fetch-depth: 0
451+
ref: refs/pull/${{ github.event.pull_request.number }}/head
452+
453+
# Generate GitHub App token for custom app identity (optional - falls back to github.token)
454+
- name: Generate GitHub App token
455+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true'
456+
id: app-token
457+
continue-on-error: true
458+
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
459+
with:
460+
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
461+
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
462+
463+
- name: Run reply
464+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
465+
continue-on-error: true
466+
uses: docker/cagent-action/review-pr/reply@latest
467+
with:
468+
thread-context: ${{ steps.thread.outputs.prompt }}
469+
comment-id: ${{ github.event.comment.id }}
470+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
471+
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
472+
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
473+
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
474+
xai-api-key: ${{ secrets.XAI_API_KEY }}
475+
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
476+
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
477+
github-token: ${{ steps.app-token.outputs.token || github.token }}

.github/workflows/self-review-pr.yml

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,186 @@ jobs:
196196
name: pr-review-feedback
197197
path: feedback/
198198
retention-days: 90
199+
200+
# ==========================================================================
201+
# REPLY TO FEEDBACK
202+
# Responds directly in the PR review thread when a user replies to an agent
203+
# comment. Runs in parallel with capture-feedback.
204+
# Uses ./ (local action) for dogfooding instead of docker/cagent-action@latest.
205+
# ==========================================================================
206+
reply-to-feedback:
207+
if: |
208+
github.event_name == 'pull_request_review_comment' &&
209+
github.event.comment.in_reply_to_id &&
210+
github.event.comment.user.type != 'Bot'
211+
runs-on: ubuntu-latest
212+
env:
213+
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
214+
215+
steps:
216+
- name: Check if reply is to agent comment
217+
id: check
218+
shell: bash
219+
env:
220+
GH_TOKEN: ${{ github.token }}
221+
PARENT_ID: ${{ github.event.comment.in_reply_to_id }}
222+
REPO: ${{ github.repository }}
223+
run: |
224+
if [ -z "$PARENT_ID" ]; then
225+
echo "is_agent=false" >> $GITHUB_OUTPUT
226+
echo "⏭️ Not a reply comment, skipping"
227+
exit 0
228+
fi
229+
230+
parent=$(gh api "repos/$REPO/pulls/comments/$PARENT_ID" 2>/dev/null || echo "{}")
231+
body=$(echo "$parent" | jq -r '.body // ""')
232+
parent_user_type=$(echo "$parent" | jq -r '.user.type // ""')
233+
234+
# Defense-in-depth: verify the root comment was posted by a Bot (agent) AND
235+
# contains the review marker but NOT the reply marker (substring overlap).
236+
# The user.type check prevents matching human comments that happen to contain
237+
# the marker text (e.g., in discussions about the review system).
238+
if [ "$parent_user_type" = "Bot" ] && \
239+
echo "$body" | grep -q "<!-- cagent-review -->" && \
240+
! echo "$body" | grep -q "<!-- cagent-review-reply -->"; then
241+
echo "is_agent=true" >> $GITHUB_OUTPUT
242+
echo "root_comment_id=$PARENT_ID" >> $GITHUB_OUTPUT
243+
244+
# Extract file path and line from the root comment for context
245+
echo "file_path=$(echo "$parent" | jq -r '.path // ""')" >> $GITHUB_OUTPUT
246+
echo "line=$(echo "$parent" | jq -r '.line // .original_line // ""')" >> $GITHUB_OUTPUT
247+
echo "✅ Reply is to an agent review comment"
248+
else
249+
echo "is_agent=false" >> $GITHUB_OUTPUT
250+
echo "⏭️ Not a reply to agent comment, skipping"
251+
fi
252+
253+
- name: Check authorization
254+
if: steps.check.outputs.is_agent == 'true'
255+
id: auth
256+
shell: bash
257+
env:
258+
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
259+
run: |
260+
case "$AUTHOR_ASSOCIATION" in
261+
OWNER|MEMBER|COLLABORATOR)
262+
echo "authorized=true" >> $GITHUB_OUTPUT
263+
echo "✅ Author is $AUTHOR_ASSOCIATION — authorized to trigger reply"
264+
;;
265+
*)
266+
echo "authorized=false" >> $GITHUB_OUTPUT
267+
echo "⏭️ Author is $AUTHOR_ASSOCIATION — not authorized for reply"
268+
;;
269+
esac
270+
271+
- name: Build thread context
272+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
273+
id: thread
274+
shell: bash
275+
env:
276+
GH_TOKEN: ${{ github.token }}
277+
ROOT_ID: ${{ steps.check.outputs.root_comment_id }}
278+
PR_NUMBER: ${{ github.event.pull_request.number }}
279+
REPO: ${{ github.repository }}
280+
FILE_PATH: ${{ steps.check.outputs.file_path }}
281+
LINE: ${{ steps.check.outputs.line }}
282+
# The triggering comment from the webhook payload — guaranteed fresh,
283+
# unlike the API which may have eventual consistency lag.
284+
TRIGGER_COMMENT_BODY: ${{ github.event.comment.body }}
285+
TRIGGER_COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
286+
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
287+
run: |
288+
# Fetch the root comment
289+
root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID")
290+
root_body=$(echo "$root" | jq -r '.body // ""')
291+
292+
# Fetch all review comments on this PR and filter to this thread.
293+
# Uses --paginate to handle PRs with >100 review comments.
294+
# Each page is processed by jq independently, then merged with jq -s.
295+
# Note: the triggering comment may not appear here due to eventual
296+
# consistency, so we append it from the webhook payload below.
297+
all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" \
298+
--jq "[.[] | select(.in_reply_to_id == $ROOT_ID)]" | jq -s 'add // [] | sort_by(.created_at)')
299+
300+
# Build the thread context and save as step output.
301+
# Use a randomized delimiter to prevent comment body content from
302+
# colliding with the GITHUB_OUTPUT heredoc terminator.
303+
DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)"
304+
305+
{
306+
echo "prompt<<$DELIM"
307+
echo "A developer replied to your review comment. Read the thread context below and respond"
308+
echo "in the same thread."
309+
echo ""
310+
echo "---"
311+
echo "REPO=$REPO"
312+
echo "PR_NUMBER=$PR_NUMBER"
313+
echo "ROOT_COMMENT_ID=$ROOT_ID"
314+
echo "FILE_PATH=$FILE_PATH"
315+
echo "LINE=$LINE"
316+
echo ""
317+
echo "[ORIGINAL REVIEW COMMENT]"
318+
echo "$root_body"
319+
echo ""
320+
321+
# Add earlier replies from the API (excludes the triggering comment
322+
# to avoid duplication if the API already has it)
323+
reply_count=$(echo "$all_comments" | jq 'length')
324+
if [ "$reply_count" -gt 0 ]; then
325+
for i in $(seq 0 $((reply_count - 1))); do
326+
comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue
327+
# Skip the triggering comment — we append it from the payload below
328+
if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then
329+
continue
330+
fi
331+
author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue
332+
body=$(echo "$all_comments" | jq -r ".[$i].body") || continue
333+
echo "[REPLY by @$author]"
334+
echo "$body"
335+
echo ""
336+
done
337+
fi
338+
339+
# Always append the triggering comment last — sourced directly from
340+
# the webhook payload so it's guaranteed to be present.
341+
echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to"
342+
echo "$TRIGGER_COMMENT_BODY"
343+
echo ""
344+
echo "$DELIM"
345+
} >> $GITHUB_OUTPUT
346+
347+
echo "✅ Built thread context with replies (triggering comment from webhook payload)"
348+
349+
# Safe to checkout PR head because the reply agent only READS files (no code execution)
350+
- name: Checkout PR head
351+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
352+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
353+
with:
354+
fetch-depth: 0
355+
ref: refs/pull/${{ github.event.pull_request.number }}/head
356+
357+
# Generate GitHub App token for custom app identity (optional - falls back to github.token)
358+
- name: Generate GitHub App token
359+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true'
360+
id: app-token
361+
continue-on-error: true
362+
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
363+
with:
364+
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
365+
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
366+
367+
- name: Run reply
368+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
369+
continue-on-error: true
370+
uses: ./review-pr/reply
371+
with:
372+
thread-context: ${{ steps.thread.outputs.prompt }}
373+
comment-id: ${{ github.event.comment.id }}
374+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
375+
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
376+
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
377+
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
378+
xai-api-key: ${{ secrets.XAI_API_KEY }}
379+
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
380+
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
381+
github-token: ${{ steps.app-token.outputs.token || github.token }}

0 commit comments

Comments
 (0)