Skip to content

Add conversational replies to PR review feedback #244

Add conversational replies to PR review feedback

Add conversational replies to PR review feedback #244

Workflow file for this run

# Dogfoods the PR review workflow on this repo's own PRs.
# Instead of calling the reusable review-pr.yml (which uses the published
# composite action), this workflow inlines the jobs and points at ./review-pr
# so that PRs changing the review logic test themselves.
name: Self PR Review
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_target:
types: [ready_for_review, opened]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
# ==========================================================================
# AUTOMATIC REVIEW FOR ORG MEMBERS
# Triggers when a PR is marked ready for review or opened (non-draft)
# Only runs for members of the docker org (supports fork-based workflow)
# ==========================================================================
auto-review:
if: |
github.event_name == 'pull_request_target' &&
!github.event.pull_request.draft
runs-on: ubuntu-latest
env:
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
steps:
- name: Check if PR author is org member
id: membership
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.CAGENT_ORG_MEMBERSHIP_TOKEN }}
script: |
const org = 'docker';
const username = context.payload.pull_request.user.login;
try {
await github.rest.orgs.checkMembershipForUser({
org: org,
username: username
});
core.setOutput('is_member', 'true');
console.log(`✅ ${username} is a ${org} org member - proceeding with auto-review`);
} catch (error) {
if (error.status === 404 || error.status === 302) {
core.setOutput('is_member', 'false');
console.log(`⏭️ ${username} is not a ${org} org member - skipping auto-review`);
} else if (error.status === 401) {
core.setFailed(
'❌ CAGENT_ORG_MEMBERSHIP_TOKEN secret is missing or invalid.\n\n' +
`This secret is required to check ${org} org membership for auto-reviews.\n\n` +
'To fix this:\n' +
'1. Create a classic PAT with read:org scope at https://github.com/settings/tokens/new\n' +
'2. Add it as an org secret named CAGENT_ORG_MEMBERSHIP_TOKEN'
);
} else {
core.setFailed(`Failed to check org membership: ${error.message}`);
}
}
# Safe to checkout PR head because review-pr only READS files (no code execution)
- name: Checkout PR head
if: steps.membership.outputs.is_member == 'true'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: refs/pull/${{ github.event.pull_request.number }}/head
# Generate GitHub App token for custom app identity (optional - falls back to github.token)
- name: Generate GitHub App token
if: steps.membership.outputs.is_member == 'true' && env.HAS_APP_SECRETS == 'true'
id: app-token
continue-on-error: true # Don't fail workflow if token generation fails
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
with:
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
- name: Run PR Review
if: steps.membership.outputs.is_member == 'true'
id: run-review
continue-on-error: true # Don't fail the calling workflow if the review errors
uses: ./review-pr
with:
pr-number: ${{ github.event.pull_request.number }}
github-token: ${{ steps.app-token.outputs.token || github.token }}
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
xai-api-key: ${{ secrets.XAI_API_KEY }}
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
# ==========================================================================
# MANUAL REVIEW PIPELINE
# Triggers when someone comments /review on a PR
# ==========================================================================
manual-review:
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')
runs-on: ubuntu-latest
env:
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
steps:
# Checkout PR head (not default branch)
# Note: Authorization is handled by the composite action's built-in check
- name: Checkout PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: refs/pull/${{ github.event.issue.number }}/head
# Generate GitHub App token for custom app identity (optional - falls back to github.token)
- name: Generate GitHub App token
if: env.HAS_APP_SECRETS == 'true'
id: app-token
continue-on-error: true # Don't fail workflow if token generation fails
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
with:
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
- name: Run PR Review
id: run-review
continue-on-error: true # Don't fail the calling workflow if the review errors
uses: ./review-pr
with:
pr-number: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
github-token: ${{ steps.app-token.outputs.token || github.token }}
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
xai-api-key: ${{ secrets.XAI_API_KEY }}
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
# ==========================================================================
# CAPTURE FEEDBACK
# Saves feedback data as an artifact for lazy processing. This job
# intentionally avoids using secrets so it works for fork PRs in public
# repos. The actual AI processing happens during the next review run,
# which has full secret access via pull_request_target or issue_comment.
# ==========================================================================
capture-feedback:
if: github.event_name == 'pull_request_review_comment' && github.event.comment.in_reply_to_id
runs-on: ubuntu-latest
steps:
- name: Check if reply is to agent comment
id: check
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PARENT_ID: ${{ github.event.comment.in_reply_to_id }}
run: |
if [ -z "$PARENT_ID" ]; then
echo "is_agent=false" >> $GITHUB_OUTPUT
echo "⏭️ Not a reply comment, skipping"
exit 0
fi
parent=$(gh api repos/${{ github.repository }}/pulls/comments/$PARENT_ID 2>/dev/null || echo "{}")
if echo "$parent" | jq -r '.body // ""' | grep -q "<!-- cagent-review -->"; then
echo "is_agent=true" >> $GITHUB_OUTPUT
echo "✅ Reply is to an agent review comment"
else
echo "is_agent=false" >> $GITHUB_OUTPUT
echo "⏭️ Not a reply to agent comment, skipping"
fi
- name: Save feedback data
if: steps.check.outputs.is_agent == 'true'
shell: bash
env:
COMMENT_JSON: ${{ toJSON(github.event.comment) }}
run: |
mkdir -p feedback
echo "$COMMENT_JSON" > feedback/feedback.json
echo "📦 Saved feedback data for async processing"
- name: Upload feedback artifact
if: steps.check.outputs.is_agent == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: pr-review-feedback
path: feedback/
retention-days: 90
# ==========================================================================
# REPLY TO FEEDBACK
# Responds directly in the PR review thread when a user replies to an agent
# comment. Runs in parallel with capture-feedback.
# Uses ./ (local action) for dogfooding instead of docker/cagent-action@latest.
# ==========================================================================
reply-to-feedback:
if: |
github.event_name == 'pull_request_review_comment' &&
github.event.comment.in_reply_to_id &&
github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
env:
HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }}
steps:
- name: Check if reply is to agent comment
id: check
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PARENT_ID: ${{ github.event.comment.in_reply_to_id }}
REPO: ${{ github.repository }}
run: |
if [ -z "$PARENT_ID" ]; then
echo "is_agent=false" >> $GITHUB_OUTPUT
echo "⏭️ Not a reply comment, skipping"
exit 0
fi
parent=$(gh api "repos/$REPO/pulls/comments/$PARENT_ID" 2>/dev/null || echo "{}")
body=$(echo "$parent" | jq -r '.body // ""')
parent_user_type=$(echo "$parent" | jq -r '.user.type // ""')
# Defense-in-depth: verify the root comment was posted by a Bot (agent) AND
# contains the review marker but NOT the reply marker (substring overlap).
# The user.type check prevents matching human comments that happen to contain
# the marker text (e.g., in discussions about the review system).
if [ "$parent_user_type" = "Bot" ] && \
echo "$body" | grep -q "<!-- cagent-review -->" && \
! echo "$body" | grep -q "<!-- cagent-review-reply -->"; then
echo "is_agent=true" >> $GITHUB_OUTPUT
echo "root_comment_id=$PARENT_ID" >> $GITHUB_OUTPUT
# Extract file path and line from the root comment for context
echo "file_path=$(echo "$parent" | jq -r '.path // ""')" >> $GITHUB_OUTPUT
echo "line=$(echo "$parent" | jq -r '.line // .original_line // ""')" >> $GITHUB_OUTPUT
echo "✅ Reply is to an agent review comment"
else
echo "is_agent=false" >> $GITHUB_OUTPUT
echo "⏭️ Not a reply to agent comment, skipping"
fi
- name: Check authorization
if: steps.check.outputs.is_agent == 'true'
id: auth
shell: bash
env:
AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }}
run: |
case "$AUTHOR_ASSOCIATION" in
OWNER|MEMBER|COLLABORATOR)
echo "authorized=true" >> $GITHUB_OUTPUT
echo "✅ Author is $AUTHOR_ASSOCIATION — authorized to trigger reply"
;;
*)
echo "authorized=false" >> $GITHUB_OUTPUT
echo "⏭️ Author is $AUTHOR_ASSOCIATION — not authorized for reply"
;;
esac
- name: Build thread context
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
id: thread
shell: bash
env:
GH_TOKEN: ${{ github.token }}
ROOT_ID: ${{ steps.check.outputs.root_comment_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
FILE_PATH: ${{ steps.check.outputs.file_path }}
LINE: ${{ steps.check.outputs.line }}
# The triggering comment from the webhook payload — guaranteed fresh,
# unlike the API which may have eventual consistency lag.
TRIGGER_COMMENT_BODY: ${{ github.event.comment.body }}
TRIGGER_COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
run: |
# Fetch the root comment
root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID")
root_body=$(echo "$root" | jq -r '.body // ""')
# Fetch all review comments on this PR and filter to this thread.
# Uses --paginate to handle PRs with >100 review comments.
# Each page is processed by jq independently, then merged with jq -s.
# Note: the triggering comment may not appear here due to eventual
# consistency, so we append it from the webhook payload below.
all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" \
--jq "[.[] | select(.in_reply_to_id == $ROOT_ID)]" | jq -s 'add // [] | sort_by(.created_at)')
# Build the thread context and save as step output.
# Use a randomized delimiter to prevent comment body content from
# colliding with the GITHUB_OUTPUT heredoc terminator.
DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)"
{
echo "prompt<<$DELIM"
echo "A developer replied to your review comment. Read the thread context below and respond"
echo "in the same thread."
echo ""
echo "---"
echo "REPO=$REPO"
echo "PR_NUMBER=$PR_NUMBER"
echo "ROOT_COMMENT_ID=$ROOT_ID"
echo "FILE_PATH=$FILE_PATH"
echo "LINE=$LINE"
echo ""
echo "[ORIGINAL REVIEW COMMENT]"
echo "$root_body"
echo ""
# Add earlier replies from the API (excludes the triggering comment
# to avoid duplication if the API already has it)
reply_count=$(echo "$all_comments" | jq 'length')
if [ "$reply_count" -gt 0 ]; then
for i in $(seq 0 $((reply_count - 1))); do
comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue
# Skip the triggering comment — we append it from the payload below
if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then
continue
fi
author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue
body=$(echo "$all_comments" | jq -r ".[$i].body") || continue
echo "[REPLY by @$author]"
echo "$body"
echo ""
done
fi
# Always append the triggering comment last — sourced directly from
# the webhook payload so it's guaranteed to be present.
echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to"
echo "$TRIGGER_COMMENT_BODY"
echo ""
echo "$DELIM"
} >> $GITHUB_OUTPUT
echo "✅ Built thread context with replies (triggering comment from webhook payload)"
# Safe to checkout PR head because the reply agent only READS files (no code execution)
- name: Checkout PR head
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: refs/pull/${{ github.event.pull_request.number }}/head
# Generate GitHub App token for custom app identity (optional - falls back to github.token)
- name: Generate GitHub App token
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true'
id: app-token
continue-on-error: true
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2
with:
app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }}
private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }}
- name: Run reply
if: steps.check.outputs.is_agent == 'true' && steps.auth.outputs.authorized == 'true'
continue-on-error: true
uses: ./review-pr/reply
with:
thread-context: ${{ steps.thread.outputs.prompt }}
comment-id: ${{ github.event.comment.id }}
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
google-api-key: ${{ secrets.GOOGLE_API_KEY }}
aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
xai-api-key: ${{ secrets.XAI_API_KEY }}
nebius-api-key: ${{ secrets.NEBIUS_API_KEY }}
mistral-api-key: ${{ secrets.MISTRAL_API_KEY }}
github-token: ${{ steps.app-token.outputs.token || github.token }}