@@ -291,3 +291,193 @@ 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 (fail early if the API call errors)
385+ root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID") || {
386+ echo "::error::Failed to fetch root comment $ROOT_ID" >&2
387+ exit 1
388+ }
389+ root_body=$(echo "$root" | jq -r '.body // ""')
390+
391+ # Fetch all review comments on this PR and filter to this thread.
392+ # Uses --paginate to handle PRs with >100 review comments.
393+ # Each page is processed by jq independently, then merged with jq -s.
394+ # Note: the triggering comment may not appear here due to eventual
395+ # consistency, so we append it from the webhook payload below.
396+ all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" \
397+ --jq "[.[] | select(.in_reply_to_id == $ROOT_ID)]" | jq -s 'add // [] | sort_by(.created_at)') || {
398+ echo "::error::Failed to fetch thread comments for PR $PR_NUMBER" >&2
399+ exit 1
400+ }
401+
402+ # Build the thread context and save as step output.
403+ # Use a randomized delimiter to prevent comment body content from
404+ # colliding with the GITHUB_OUTPUT heredoc terminator.
405+ DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)"
406+
407+ {
408+ echo "prompt<<$DELIM"
409+ echo "A developer replied to your review comment. Read the thread context below and respond"
410+ echo "in the same thread."
411+ echo ""
412+ echo "---"
413+ echo "REPO=$REPO"
414+ echo "PR_NUMBER=$PR_NUMBER"
415+ echo "ROOT_COMMENT_ID=$ROOT_ID"
416+ echo "FILE_PATH=$FILE_PATH"
417+ echo "LINE=$LINE"
418+ echo ""
419+ echo "[ORIGINAL REVIEW COMMENT]"
420+ echo "$root_body"
421+ echo ""
422+
423+ # Add earlier replies from the API (excludes the triggering comment
424+ # to avoid duplication if the API already has it)
425+ reply_count=$(echo "$all_comments" | jq 'length')
426+ if [ "$reply_count" -gt 0 ]; then
427+ for i in $(seq 0 $((reply_count - 1))); do
428+ comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue
429+ # Skip the triggering comment — we append it from the payload below
430+ if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then
431+ continue
432+ fi
433+ author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue
434+ body=$(echo "$all_comments" | jq -r ".[$i].body") || continue
435+ echo "[REPLY by @$author]"
436+ echo "$body"
437+ echo ""
438+ done
439+ fi
440+
441+ # Always append the triggering comment last — sourced directly from
442+ # the webhook payload so it's guaranteed to be present.
443+ echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to"
444+ echo "$TRIGGER_COMMENT_BODY"
445+ echo ""
446+ echo "$DELIM"
447+ } >> $GITHUB_OUTPUT
448+
449+ echo "✅ Built thread context with replies (triggering comment from webhook payload)"
450+
451+ # Safe to checkout PR head because the reply agent only READS files (no code execution)
452+ - name : Checkout PR head
453+ if : steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
454+ uses : actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
455+ with :
456+ fetch-depth : 0
457+ ref : refs/pull/${{ github.event.pull_request.number }}/head
458+
459+ # Generate GitHub App token for custom app identity (optional - falls back to github.token)
460+ - name : Generate GitHub App token
461+ if : steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true'
462+ id : app-token
463+ continue-on-error : true
464+ uses : tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
465+ with :
466+ app_id : ${{ secrets.CAGENT_REVIEWER_APP_ID }}
467+ private_key : ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
468+
469+ - name : Run reply
470+ if : steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
471+ continue-on-error : true
472+ uses : docker/cagent-action/review-pr/reply@latest
473+ with :
474+ thread-context : ${{ steps.thread.outputs.prompt }}
475+ comment-id : ${{ github.event.comment.id }}
476+ anthropic-api-key : ${{ secrets.ANTHROPIC_API_KEY }}
477+ openai-api-key : ${{ secrets.OPENAI_API_KEY }}
478+ google-api-key : ${{ secrets.GOOGLE_API_KEY }}
479+ aws-bearer-token-bedrock : ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
480+ xai-api-key : ${{ secrets.XAI_API_KEY }}
481+ nebius-api-key : ${{ secrets.NEBIUS_API_KEY }}
482+ mistral-api-key : ${{ secrets.MISTRAL_API_KEY }}
483+ github-token : ${{ steps.app-token.outputs.token || github.token }}
0 commit comments