@@ -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