diff --git a/.github/workflows/review-pr.yml b/.github/workflows/review-pr.yml index d8730df..5c32c03 100644 --- a/.github/workflows/review-pr.yml +++ b/.github/workflows/review-pr.yml @@ -291,3 +291,208 @@ jobs: 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 — this job handles the + # synchronous conversational reply, while capture-feedback saves the artifact + # for async learning on the next review run (resilient fallback). + # ========================================================================== + 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") || { + echo "::warning::Failed to fetch parent comment $PARENT_ID" >&2 + echo "is_agent=false" >> $GITHUB_OUTPUT + exit 0 + } + # Validate required fields exist before extracting + if ! echo "$parent" | jq -e '.user.type and .body' > /dev/null 2>&1; then + echo "::warning::Parent comment has unexpected structure" >&2 + echo "is_agent=false" >> $GITHUB_OUTPUT + exit 0 + fi + 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 "" && \ + ! echo "$body" | grep -q ""; 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 (fail early if the API call errors) + root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID") || { + echo "::error::Failed to fetch root comment $ROOT_ID" >&2 + exit 1 + } + 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)') || { + echo "::error::Failed to fetch thread comments for PR $PR_NUMBER" >&2 + exit 1 + } + + # 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 + # Skip bot replies to avoid the agent responding to its own previous replies + user_type=$(echo "$all_comments" | jq -r ".[$i].user.type") || continue + if [ "$user_type" = "Bot" ]; 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: docker/cagent-action/review-pr/reply@latest + 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 }} diff --git a/.github/workflows/self-review-pr.yml b/.github/workflows/self-review-pr.yml index c2ce8e0..ee41d57 100644 --- a/.github/workflows/self-review-pr.yml +++ b/.github/workflows/self-review-pr.yml @@ -196,3 +196,207 @@ jobs: 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") || { + echo "::warning::Failed to fetch parent comment $PARENT_ID" >&2 + echo "is_agent=false" >> $GITHUB_OUTPUT + exit 0 + } + # Validate required fields exist before extracting + if ! echo "$parent" | jq -e '.user.type and .body' > /dev/null 2>&1; then + echo "::warning::Parent comment has unexpected structure" >&2 + echo "is_agent=false" >> $GITHUB_OUTPUT + exit 0 + fi + 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 "" && \ + ! echo "$body" | grep -q ""; 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 (fail early if the API call errors) + root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID") || { + echo "::error::Failed to fetch root comment $ROOT_ID" >&2 + exit 1 + } + 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)') || { + echo "::error::Failed to fetch thread comments for PR $PR_NUMBER" >&2 + exit 1 + } + + # 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 + # Skip bot replies to avoid the agent responding to its own previous replies + user_type=$(echo "$all_comments" | jq -r ".[$i].user.type") || continue + if [ "$user_type" = "Bot" ]; 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 }} diff --git a/review-pr/README.md b/review-pr/README.md index 390da85..2c57276 100644 --- a/review-pr/README.md +++ b/review-pr/README.md @@ -54,7 +54,7 @@ The workflow automatically handles: | ----------------------- | --------------------------------------------------------------------------------------- | | PR opened/ready | Auto-reviews PRs from your org members (if `CAGENT_ORG_MEMBERSHIP_TOKEN` is configured) | | `/review` comment | Manual review on any PR | -| Reply to review comment | Learns from feedback to improve future reviews | +| Reply to review comment | Responds in-thread and learns from feedback to improve future reviews | --- @@ -330,12 +330,32 @@ PR Diff → Drafter (hypotheses) → Verifier (confirm) → Post Comments ### Learning System -When you reply to a review comment: +When you reply to a review comment, two things happen in parallel: -1. The `capture-feedback` job checks if it's a reply to an agent comment (via `` marker) -2. If yes, saves the feedback as a GitHub Actions artifact (no secrets required — works for fork PRs) -3. On the next review run, pending feedback artifacts are downloaded and processed into the memory database -4. Future reviews use these learnings to avoid repeating the same mistakes +**Synchronous reply** (`reply-to-feedback` job): +1. Checks if the reply is to an agent comment (via `` marker) +2. Verifies the author is an org member/collaborator (authorization gate) +3. Builds the full thread context (original comment + all replies in chronological order) +4. Runs a Sonnet-powered reply agent that posts a contextual response in the same thread +5. The agent also stores learnings in the memory database for future reviews + +**Async artifact capture** (`capture-feedback` job): +1. Saves the feedback as a GitHub Actions artifact (no secrets required — works for fork PRs) +2. On the next review run, pending feedback artifacts are downloaded and processed into the memory database +3. Acts as a resilient fallback if the reply agent fails or isn't configured + +This dual approach means the developer gets an immediate conversational response while also ensuring learnings are captured even if the reply job encounters an issue. + +### Conversational Replies + +The reviewer supports true multi-turn conversation in PR review threads. When you reply to a review comment: + +- **Ask a question** — the agent explains its reasoning, references specific code, and offers suggestions +- **Correct a false positive** — the agent acknowledges the mistake and remembers it for future reviews +- **Disagree** — the agent engages thoughtfully, discusses trade-offs, and considers your perspective +- **Add context** — the agent thanks you, reassesses its finding, and stores the insight + +Agent replies are marked with `` (distinct from `` on original review comments) to prevent infinite loops. Multi-turn threading works automatically because GitHub's `in_reply_to_id` always points to the root comment. **Memory persistence:** The memory database is stored in GitHub Actions cache. Each review run restores the previous cache, processes any pending feedback, runs the review, and saves with a unique key. Old caches are automatically cleaned up (keeping the 5 most recent). diff --git a/review-pr/agents/pr-review-reply.yaml b/review-pr/agents/pr-review-reply.yaml new file mode 100644 index 0000000..db274e5 --- /dev/null +++ b/review-pr/agents/pr-review-reply.yaml @@ -0,0 +1,99 @@ +models: + sonnet: + provider: anthropic + model: claude-sonnet-4-5 + max_tokens: 4096 + +agents: + root: + model: sonnet + description: Responds to developer feedback on review comments + instruction: | + A developer replied to one of your review comments on a pull request. You are having a + conversation in the GitHub PR review thread. Read the thread context provided in the prompt, + understand the developer's reply, and respond helpfully. + + ## Thread Context + + The prompt contains the full conversation thread formatted as: + ``` + [ORIGINAL REVIEW COMMENT] + (your original inline review comment) + + [REPLY 1 by @username] + (first reply in the thread) + + [REPLY 2 by @username] + (second reply, etc.) + ``` + + The last reply is the one you're responding to. Earlier replies provide conversation history. + + ## Reply Types + + Analyze the developer's reply and respond accordingly: + + - **Correction**: They're telling you the finding was wrong (false positive). Acknowledge the + mistake gracefully, explain what you misunderstood, and use `add_memory` to remember this + pattern so you don't repeat it. + - **Question**: They're asking for clarification or more detail. Provide a helpful explanation, + reference the specific code if needed (use `read_file` to check the source), and offer + concrete suggestions. + - **Agreement**: They agree with your finding and may be adding context. Acknowledge their + input, and use `add_memory` to store any additional context they provide. + - **Disagreement**: They disagree but aren't saying you're wrong outright. Engage thoughtfully + — explain your reasoning, acknowledge their perspective, and discuss trade-offs. Don't be + defensive. + - **Context**: They're providing additional information about why the code is the way it is. + Thank them for the context, reassess your finding in light of it, and store the insight + with `add_memory`. + + ## Posting Your Reply + + After formulating your response, post it using `gh api` with JSON input piped via stdin. + IMPORTANT: You MUST use the `jq -n --arg` pattern shown below. NEVER construct the JSON + body using shell string interpolation (e.g., `echo "{\"body\": \"$text\"}"`) — this creates + a command injection vulnerability if the response contains quotes or special characters. + + ```bash + jq -n \ + --arg body "YOUR RESPONSE + + " \ + --argjson reply_to ROOT_COMMENT_ID \ + '{body: $body, in_reply_to_id: $reply_to}' | \ + gh api repos/{owner}/{repo}/pulls/{pr}/comments --input - + ``` + + The owner, repo, PR number, and root comment ID are provided in the prompt as environment-style + variables at the top of the thread context. + + ## Response Guidelines + + - Keep responses concise: 1-3 paragraphs max + - Be collaborative, not defensive — you're a helpful reviewer, not an authority + - If you were wrong, say so clearly. Developers respect honesty over saving face + - Reference specific code when it helps (use `read_file` to check source files) + - When discussing trade-offs, present both sides fairly + - Never repeat the original finding verbatim — the developer already read it + - End with `` marker (distinct from ``) + for identification. This marker MUST be on its own line, separated by a blank line + + ## Learning + + After posting your reply, always use `add_memory` to store what you learned: + - If corrected: what you got wrong and why, so you avoid it in future reviews + - If given context: the project-specific pattern or convention + - If asked a question: what was unclear about your original comment (improve future clarity) + + toolsets: + - type: memory + path: .cache/pr-review-memory.db + - type: shell + - type: filesystem + tools: [read_file, read_multiple_files, list_directory] + +permissions: + allow: + - shell:cmd=gh api repos/*/pulls/*/comments* + - shell:cmd=jq * diff --git a/review-pr/reply/action.yml b/review-pr/reply/action.yml new file mode 100644 index 0000000..7422373 --- /dev/null +++ b/review-pr/reply/action.yml @@ -0,0 +1,97 @@ +name: "PR Review Reply" +description: "Responds to developer feedback on PR review comments" +author: "Docker" + +inputs: + thread-context: + description: "Thread context (original comment + replies) to respond to" + required: true + comment-id: + description: "ID of the triggering comment (for failure notification reaction)" + required: false + anthropic-api-key: + description: "Anthropic API key" + required: false + openai-api-key: + description: "OpenAI API key" + required: false + google-api-key: + description: "Google API key for Gemini models" + required: false + aws-bearer-token-bedrock: + description: "AWS Bearer token for Bedrock models" + required: false + xai-api-key: + description: "xAI API key for Grok models" + required: false + nebius-api-key: + description: "Nebius API key" + required: false + mistral-api-key: + description: "Mistral API key" + required: false + github-token: + description: "GitHub token for API access" + required: false + +outputs: + agent-outcome: + description: "Outcome of the reply agent (success/failure/skipped)" + value: ${{ steps.run-reply.outcome }} + +runs: + using: "composite" + steps: + - name: Ensure cache directory exists + shell: bash + run: mkdir -p "${{ github.workspace }}/.cache" + + - name: Restore reviewer memory + id: restore-memory + uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: ${{ github.workspace }}/.cache/pr-review-memory.db + key: pr-review-memory-${{ github.repository }}-reply-${{ github.run_id }} + restore-keys: | + pr-review-memory-${{ github.repository }}-reply- + pr-review-memory-${{ github.repository }}- + + - name: Run reply agent + id: run-reply + continue-on-error: true + uses: docker/cagent-action@latest + with: + agent: ${{ github.action_path }}/../agents/pr-review-reply.yaml + prompt: ${{ inputs.thread-context }} + timeout: "300" + anthropic-api-key: ${{ inputs.anthropic-api-key }} + openai-api-key: ${{ inputs.openai-api-key }} + google-api-key: ${{ inputs.google-api-key }} + aws-bearer-token-bedrock: ${{ inputs.aws-bearer-token-bedrock }} + xai-api-key: ${{ inputs.xai-api-key }} + nebius-api-key: ${{ inputs.nebius-api-key }} + mistral-api-key: ${{ inputs.mistral-api-key }} + github-token: ${{ inputs.github-token }} + + - name: Save reviewer memory + if: always() && steps.run-reply.outcome != 'skipped' + continue-on-error: true + uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: ${{ github.workspace }}/.cache/pr-review-memory.db + key: pr-review-memory-${{ github.repository }}-reply-${{ github.run_id }} + + # Add a "confused" reaction to the triggering comment if the agent failed, + # so the developer knows their reply wasn't processed. + - name: React on failure + if: always() && steps.run-reply.outcome == 'failure' && inputs.comment-id != '' + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + REPO: ${{ github.repository }} + COMMENT_ID: ${{ inputs.comment-id }} + run: | + gh api "repos/$REPO/pulls/comments/$COMMENT_ID/reactions" \ + -f content="confused" --silent || true + echo "😕 Added reaction to indicate reply failed"