Skip to content

Commit efc3bcc

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

File tree

5 files changed

+711
-6
lines changed

5 files changed

+711
-6
lines changed

.github/workflows/review-pr.yml

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,245 @@ jobs:
343343
name: pr-review-feedback
344344
path: feedback/
345345
retention-days: 90
346+
347+
# ==========================================================================
348+
# REPLY TO FEEDBACK
349+
# Responds directly in the PR review thread when a user replies to an agent
350+
# comment. Runs in parallel with capture-feedback — this job handles the
351+
# synchronous conversational reply, while capture-feedback saves the artifact
352+
# for async learning on the next review run (resilient fallback).
353+
# ==========================================================================
354+
reply-to-feedback:
355+
if: |
356+
github.event_name == 'pull_request_review_comment' &&
357+
github.event.comment.in_reply_to_id &&
358+
github.event.comment.user.type != 'Bot'
359+
runs-on: ubuntu-latest
360+
env:
361+
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
362+
363+
steps:
364+
- name: Check if reply is to agent comment
365+
id: check
366+
shell: bash
367+
env:
368+
GH_TOKEN: ${{ github.token }}
369+
PARENT_ID: ${{ github.event.comment.in_reply_to_id }}
370+
REPO: ${{ github.repository }}
371+
run: |
372+
if [ -z "$PARENT_ID" ]; then
373+
echo "is_agent=false" >> $GITHUB_OUTPUT
374+
echo "⏭️ Not a reply comment, skipping"
375+
exit 0
376+
fi
377+
378+
parent=$(gh api "repos/$REPO/pulls/comments/$PARENT_ID") || {
379+
echo "::warning::Failed to fetch parent comment $PARENT_ID" >&2
380+
echo "is_agent=false" >> $GITHUB_OUTPUT
381+
exit 0
382+
}
383+
# Validate required fields exist before extracting
384+
if ! echo "$parent" | jq -e '.user.type and .body' > /dev/null 2>&1; then
385+
echo "::warning::Parent comment has unexpected structure" >&2
386+
echo "is_agent=false" >> $GITHUB_OUTPUT
387+
exit 0
388+
fi
389+
body=$(echo "$parent" | jq -r '.body')
390+
parent_user_type=$(echo "$parent" | jq -r '.user.type')
391+
392+
# Defense-in-depth: verify the root comment was posted by a Bot (agent) AND
393+
# contains the review marker but NOT the reply marker (substring overlap).
394+
# The user.type check prevents matching human comments that happen to contain
395+
# the marker text (e.g., in discussions about the review system).
396+
if [ "$parent_user_type" = "Bot" ] && \
397+
echo "$body" | grep -q "<!-- cagent-review -->" && \
398+
! echo "$body" | grep -q "<!-- cagent-review-reply -->"; then
399+
echo "is_agent=true" >> $GITHUB_OUTPUT
400+
echo "root_comment_id=$PARENT_ID" >> $GITHUB_OUTPUT
401+
402+
# Extract file path and line from the root comment for context
403+
echo "file_path=$(echo "$parent" | jq -r '.path // ""')" >> $GITHUB_OUTPUT
404+
echo "line=$(echo "$parent" | jq -r '.line // .original_line // ""')" >> $GITHUB_OUTPUT
405+
echo "✅ Reply is to an agent review comment"
406+
else
407+
echo "is_agent=false" >> $GITHUB_OUTPUT
408+
echo "⏭️ Not a reply to agent comment, skipping"
409+
fi
410+
411+
- name: Check authorization
412+
if: steps.check.outputs.is_agent == 'true'
413+
id: auth
414+
shell: bash
415+
env:
416+
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
417+
run: |
418+
case "$AUTHOR_ASSOCIATION" in
419+
OWNER|MEMBER|COLLABORATOR)
420+
echo "authorized=true" >> $GITHUB_OUTPUT
421+
echo "✅ Author is $AUTHOR_ASSOCIATION — authorized to trigger reply"
422+
;;
423+
*)
424+
echo "authorized=false" >> $GITHUB_OUTPUT
425+
echo "⏭️ Author is $AUTHOR_ASSOCIATION — not authorized for reply"
426+
;;
427+
esac
428+
429+
- name: Notify unauthorized user
430+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'false'
431+
continue-on-error: true
432+
shell: bash
433+
env:
434+
GH_TOKEN: ${{ github.token }}
435+
REPO: ${{ github.repository }}
436+
PR_NUMBER: ${{ github.event.pull_request.number }}
437+
ROOT_COMMENT_ID: ${{ steps.check.outputs.root_comment_id }}
438+
AUTHOR: ${{ github.event.comment.user.login }}
439+
run: |
440+
jq -n \
441+
--arg body "Sorry @$AUTHOR, conversational replies are currently available to repository collaborators only. Your feedback has still been captured and will be used to improve future reviews.
442+
443+
<!-- cagent-review-reply -->" \
444+
--argjson reply_to "$ROOT_COMMENT_ID" \
445+
'{body: $body, in_reply_to_id: $reply_to}' | \
446+
gh api "repos/$REPO/pulls/$PR_NUMBER/comments" --input -
447+
448+
- name: Build thread context
449+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
450+
id: thread
451+
shell: bash
452+
env:
453+
GH_TOKEN: ${{ github.token }}
454+
ROOT_ID: ${{ steps.check.outputs.root_comment_id }}
455+
PR_NUMBER: ${{ github.event.pull_request.number }}
456+
REPO: ${{ github.repository }}
457+
FILE_PATH: ${{ steps.check.outputs.file_path }}
458+
LINE: ${{ steps.check.outputs.line }}
459+
# The triggering comment from the webhook payload — guaranteed fresh,
460+
# unlike the API which may have eventual consistency lag.
461+
TRIGGER_COMMENT_BODY: ${{ github.event.comment.body }}
462+
TRIGGER_COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
463+
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
464+
run: |
465+
# Fetch the root comment (fail early if the API call errors)
466+
root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID") || {
467+
echo "::error::Failed to fetch root comment $ROOT_ID" >&2
468+
exit 1
469+
}
470+
root_body=$(echo "$root" | jq -r '.body // ""')
471+
472+
# Fetch all review comments on this PR and filter to this thread.
473+
# Uses --paginate to handle PRs with >100 review comments.
474+
# Each page is processed by jq independently, then merged with jq -s.
475+
# Note: the triggering comment may not appear here due to eventual
476+
# consistency, so we append it from the webhook payload below.
477+
all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" | \
478+
jq -s --arg root_id "$ROOT_ID" \
479+
'[.[][] | select((.in_reply_to_id | tostring) == $root_id)] | sort_by(.created_at)') || {
480+
echo "::error::Failed to fetch thread comments for PR $PR_NUMBER" >&2
481+
exit 1
482+
}
483+
484+
# Build the thread context and save as step output.
485+
# Use a randomized delimiter to prevent comment body content from
486+
# colliding with the GITHUB_OUTPUT heredoc terminator.
487+
DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)"
488+
489+
{
490+
echo "prompt<<$DELIM"
491+
echo "A developer replied to your review comment. Read the thread context below and respond"
492+
echo "in the same thread."
493+
echo ""
494+
echo "---"
495+
echo "REPO=$REPO"
496+
echo "PR_NUMBER=$PR_NUMBER"
497+
echo "ROOT_COMMENT_ID=$ROOT_ID"
498+
echo "FILE_PATH=$FILE_PATH"
499+
echo "LINE=$LINE"
500+
echo ""
501+
echo "[ORIGINAL REVIEW COMMENT]"
502+
echo "$root_body"
503+
echo ""
504+
505+
# Add earlier replies from the API (excludes the triggering comment
506+
# to avoid duplication if the API already has it)
507+
reply_count=$(echo "$all_comments" | jq 'length')
508+
if [ "$reply_count" -gt 0 ]; then
509+
for i in $(seq 0 $((reply_count - 1))); do
510+
comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue
511+
# Skip the triggering comment — we append it from the payload below
512+
if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then
513+
continue
514+
fi
515+
user_type=$(echo "$all_comments" | jq -r ".[$i].user.type") || continue
516+
author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue
517+
body=$(echo "$all_comments" | jq -r ".[$i].body") || continue
518+
if [ "$user_type" = "Bot" ]; then
519+
echo "[YOUR PREVIOUS REPLY by @$author]"
520+
else
521+
echo "[REPLY by @$author]"
522+
fi
523+
echo "$body"
524+
echo ""
525+
done
526+
fi
527+
528+
# Always append the triggering comment last — sourced directly from
529+
# the webhook payload so it's guaranteed to be present.
530+
echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to"
531+
echo "$TRIGGER_COMMENT_BODY"
532+
echo ""
533+
echo "$DELIM"
534+
} >> $GITHUB_OUTPUT
535+
536+
echo "✅ Built thread context with replies (triggering comment from webhook payload)"
537+
538+
# Safe to checkout PR head because the reply agent only READS files (no code execution)
539+
- name: Checkout PR head
540+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
541+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
542+
with:
543+
fetch-depth: 0
544+
ref: refs/pull/${{ github.event.pull_request.number }}/head
545+
546+
# Generate GitHub App token for custom app identity (optional - falls back to github.token)
547+
- name: Generate GitHub App token
548+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true'
549+
id: app-token
550+
continue-on-error: true
551+
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
552+
with:
553+
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
554+
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
555+
556+
- name: Run reply
557+
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
558+
continue-on-error: true
559+
uses: docker/cagent-action/review-pr/reply@latest
560+
with:
561+
thread-context: ${{ steps.thread.outputs.prompt }}
562+
comment-id: ${{ github.event.comment.id }}
563+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
564+
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
565+
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
566+
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
567+
xai-api-key: ${{ secrets.XAI_API_KEY }}
568+
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
569+
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
570+
github-token: ${{ steps.app-token.outputs.token || github.token }}
571+
572+
- name: React on thread-build failure
573+
if: >-
574+
always() &&
575+
steps.check.outputs.is_agent == 'true' &&
576+
steps.auth.outputs.authorized == 'true' &&
577+
steps.thread.outcome == 'failure'
578+
continue-on-error: true
579+
shell: bash
580+
env:
581+
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
582+
REPO: ${{ github.repository }}
583+
COMMENT_ID: ${{ github.event.comment.id }}
584+
run: |
585+
gh api "repos/$REPO/pulls/comments/$COMMENT_ID/reactions" \
586+
-f content="confused" --silent || true
587+
echo "😕 Thread context build failed — added confused reaction"

0 commit comments

Comments
 (0)