Skip to content

Commit b66169d

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

File tree

5 files changed

+631
-6
lines changed

5 files changed

+631
-6
lines changed

.github/workflows/review-pr.yml

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

0 commit comments

Comments
 (0)