|
| 1 | +# Handles AI replies to feedback on review comments. |
| 2 | +# Triggered by workflow_run so it always runs in the base repo context |
| 3 | +# with full permissions and secrets — even for fork PRs in public repos. |
| 4 | +# |
| 5 | +# The triggering workflow (Self PR Review → capture-feedback) uploads |
| 6 | +# a pr-review-feedback artifact containing: |
| 7 | +# feedback.json — the raw comment payload |
| 8 | +# metadata.json — PR number, repo, comment IDs, author, is_agent flag |
| 9 | + |
| 10 | +name: Reply to Feedback |
| 11 | + |
| 12 | +on: |
| 13 | + workflow_run: |
| 14 | + workflows: ["Self PR Review"] |
| 15 | + types: [completed] |
| 16 | + |
| 17 | +permissions: |
| 18 | + contents: read |
| 19 | + pull-requests: write |
| 20 | + issues: write |
| 21 | + actions: read # Required to download artifacts from the triggering run |
| 22 | + |
| 23 | +jobs: |
| 24 | + reply: |
| 25 | + # Only run if the triggering workflow succeeded (artifact was uploaded) |
| 26 | + if: github.event.workflow_run.conclusion == 'success' |
| 27 | + runs-on: ubuntu-latest |
| 28 | + env: |
| 29 | + HAS_APP_SECRETS: ${{ secrets.CAGENT_REVIEWER_APP_ID != '' }} |
| 30 | + |
| 31 | + steps: |
| 32 | + # ---------------------------------------------------------------- |
| 33 | + # Download artifact from the triggering workflow run |
| 34 | + # ---------------------------------------------------------------- |
| 35 | + - name: Download feedback artifact |
| 36 | + id: download |
| 37 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 38 | + with: |
| 39 | + script: | |
| 40 | + const runId = context.payload.workflow_run.id; |
| 41 | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ |
| 42 | + owner: context.repo.owner, |
| 43 | + repo: context.repo.repo, |
| 44 | + run_id: runId |
| 45 | + }); |
| 46 | +
|
| 47 | + const match = artifacts.data.artifacts.find(a => a.name === 'pr-review-feedback'); |
| 48 | + if (!match) { |
| 49 | + console.log('⏭️ No pr-review-feedback artifact found — not a feedback event'); |
| 50 | + core.setOutput('found', 'false'); |
| 51 | + return; |
| 52 | + } |
| 53 | +
|
| 54 | + const zip = await github.rest.actions.downloadArtifact({ |
| 55 | + owner: context.repo.owner, |
| 56 | + repo: context.repo.repo, |
| 57 | + artifact_id: match.id, |
| 58 | + archive_format: 'zip' |
| 59 | + }); |
| 60 | +
|
| 61 | + const fs = require('fs'); |
| 62 | + fs.writeFileSync('/tmp/feedback.zip', Buffer.from(zip.data)); |
| 63 | + core.setOutput('found', 'true'); |
| 64 | + console.log('✅ Downloaded feedback artifact'); |
| 65 | +
|
| 66 | + - name: Extract artifact |
| 67 | + if: steps.download.outputs.found == 'true' |
| 68 | + shell: bash |
| 69 | + run: | |
| 70 | + mkdir -p /tmp/feedback |
| 71 | + unzip -o /tmp/feedback.zip -d /tmp/feedback |
| 72 | +
|
| 73 | + # ---------------------------------------------------------------- |
| 74 | + # Read metadata and decide whether to proceed |
| 75 | + # ---------------------------------------------------------------- |
| 76 | + - name: Read metadata |
| 77 | + if: steps.download.outputs.found == 'true' |
| 78 | + id: meta |
| 79 | + shell: bash |
| 80 | + run: | |
| 81 | + if [ ! -f /tmp/feedback/metadata.json ]; then |
| 82 | + echo "⏭️ No metadata.json in artifact — legacy artifact, skipping" |
| 83 | + echo "proceed=false" >> $GITHUB_OUTPUT |
| 84 | + exit 0 |
| 85 | + fi |
| 86 | +
|
| 87 | + # Extract fields from metadata |
| 88 | + PR_NUMBER=$(jq -r '.pr_number' /tmp/feedback/metadata.json) |
| 89 | + REPO=$(jq -r '.repo' /tmp/feedback/metadata.json) |
| 90 | + PARENT_COMMENT_ID=$(jq -r '.parent_comment_id' /tmp/feedback/metadata.json) |
| 91 | + COMMENT_ID=$(jq -r '.comment_id' /tmp/feedback/metadata.json) |
| 92 | + AUTHOR=$(jq -r '.author' /tmp/feedback/metadata.json) |
| 93 | + AUTHOR_TYPE=$(jq -r '.author_type' /tmp/feedback/metadata.json) |
| 94 | + IS_AGENT=$(jq -r '.is_agent_comment' /tmp/feedback/metadata.json) |
| 95 | + FILE_PATH=$(jq -r '.file_path' /tmp/feedback/metadata.json) |
| 96 | + LINE=$(jq -r '.line' /tmp/feedback/metadata.json) |
| 97 | +
|
| 98 | + # Validate PR number is numeric |
| 99 | + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || [ "$PR_NUMBER" -lt 1 ]; then |
| 100 | + echo "::error::Invalid PR number: $PR_NUMBER" |
| 101 | + echo "proceed=false" >> $GITHUB_OUTPUT |
| 102 | + exit 0 |
| 103 | + fi |
| 104 | +
|
| 105 | + # Skip if not an agent comment or if commenter is a bot |
| 106 | + if [ "$IS_AGENT" != "true" ] || [ "$AUTHOR_TYPE" = "Bot" ]; then |
| 107 | + echo "⏭️ Not an agent comment reply or author is a bot — skipping" |
| 108 | + echo "proceed=false" >> $GITHUB_OUTPUT |
| 109 | + exit 0 |
| 110 | + fi |
| 111 | +
|
| 112 | + echo "proceed=true" >> $GITHUB_OUTPUT |
| 113 | + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT |
| 114 | + echo "repo=$REPO" >> $GITHUB_OUTPUT |
| 115 | + echo "parent_comment_id=$PARENT_COMMENT_ID" >> $GITHUB_OUTPUT |
| 116 | + echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT |
| 117 | + echo "author=$AUTHOR" >> $GITHUB_OUTPUT |
| 118 | + echo "file_path=$FILE_PATH" >> $GITHUB_OUTPUT |
| 119 | + echo "line=$LINE" >> $GITHUB_OUTPUT |
| 120 | +
|
| 121 | + echo "✅ Metadata loaded: PR #$PR_NUMBER, comment $COMMENT_ID by @$AUTHOR" |
| 122 | +
|
| 123 | + # ---------------------------------------------------------------- |
| 124 | + # Add 👀 reaction so the user knows their reply was received |
| 125 | + # ---------------------------------------------------------------- |
| 126 | + - name: Add eyes reaction |
| 127 | + if: steps.meta.outputs.proceed == 'true' |
| 128 | + continue-on-error: true |
| 129 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 130 | + env: |
| 131 | + COMMENT_ID: ${{ steps.meta.outputs.comment_id }} |
| 132 | + REPO: ${{ steps.meta.outputs.repo }} |
| 133 | + with: |
| 134 | + script: | |
| 135 | + const [owner, repo] = process.env.REPO.split('/'); |
| 136 | + await github.rest.reactions.createForPullRequestReviewComment({ |
| 137 | + owner, |
| 138 | + repo, |
| 139 | + comment_id: parseInt(process.env.COMMENT_ID, 10), |
| 140 | + content: 'eyes' |
| 141 | + }); |
| 142 | + console.log('👀 Added eyes reaction to triggering comment'); |
| 143 | +
|
| 144 | + # ---------------------------------------------------------------- |
| 145 | + # Authorization check via org membership |
| 146 | + # ---------------------------------------------------------------- |
| 147 | + - name: Check authorization |
| 148 | + if: steps.meta.outputs.proceed == 'true' |
| 149 | + id: auth |
| 150 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 151 | + env: |
| 152 | + USERNAME: ${{ steps.meta.outputs.author }} |
| 153 | + with: |
| 154 | + github-token: ${{ secrets.CAGENT_ORG_MEMBERSHIP_TOKEN }} |
| 155 | + script: | |
| 156 | + const org = 'docker'; |
| 157 | + const username = process.env.USERNAME; |
| 158 | +
|
| 159 | + try { |
| 160 | + await github.rest.orgs.checkMembershipForUser({ org, username }); |
| 161 | + core.setOutput('authorized', 'true'); |
| 162 | + console.log(`✅ ${username} is a ${org} org member — authorized`); |
| 163 | + } catch (error) { |
| 164 | + if (error.status === 404 || error.status === 302) { |
| 165 | + core.setOutput('authorized', 'false'); |
| 166 | + console.log(`⏭️ ${username} is not a ${org} org member — not authorized`); |
| 167 | + } else if (error.status === 401) { |
| 168 | + core.warning('CAGENT_ORG_MEMBERSHIP_TOKEN secret is missing or invalid'); |
| 169 | + core.setOutput('authorized', 'false'); |
| 170 | + } else { |
| 171 | + core.warning(`Failed to check org membership: ${error.message}`); |
| 172 | + core.setOutput('authorized', 'false'); |
| 173 | + } |
| 174 | + } |
| 175 | +
|
| 176 | + - name: Notify unauthorized user |
| 177 | + if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'false' |
| 178 | + continue-on-error: true |
| 179 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 180 | + env: |
| 181 | + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} |
| 182 | + REPO: ${{ steps.meta.outputs.repo }} |
| 183 | + ROOT_COMMENT_ID: ${{ steps.meta.outputs.parent_comment_id }} |
| 184 | + AUTHOR: ${{ steps.meta.outputs.author }} |
| 185 | + with: |
| 186 | + script: | |
| 187 | + const [owner, repo] = process.env.REPO.split('/'); |
| 188 | + const body = 'Sorry @' + process.env.AUTHOR + ', conversational replies are currently available to repository collaborators only. Your feedback has still been captured and will be used to improve future reviews.\n\n<!-- cagent-review-reply -->'; |
| 189 | + await github.rest.pulls.createReplyForReviewComment({ |
| 190 | + owner, |
| 191 | + repo, |
| 192 | + pull_number: parseInt(process.env.PR_NUMBER, 10), |
| 193 | + comment_id: parseInt(process.env.ROOT_COMMENT_ID, 10), |
| 194 | + body |
| 195 | + }); |
| 196 | +
|
| 197 | + # ---------------------------------------------------------------- |
| 198 | + # Build thread context from API data |
| 199 | + # ---------------------------------------------------------------- |
| 200 | + - name: Build thread context |
| 201 | + if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true' |
| 202 | + id: thread |
| 203 | + shell: bash |
| 204 | + env: |
| 205 | + GH_TOKEN: ${{ github.token }} |
| 206 | + ROOT_ID: ${{ steps.meta.outputs.parent_comment_id }} |
| 207 | + PR_NUMBER: ${{ steps.meta.outputs.pr_number }} |
| 208 | + REPO: ${{ steps.meta.outputs.repo }} |
| 209 | + FILE_PATH: ${{ steps.meta.outputs.file_path }} |
| 210 | + LINE: ${{ steps.meta.outputs.line }} |
| 211 | + TRIGGER_COMMENT_ID: ${{ steps.meta.outputs.comment_id }} |
| 212 | + run: | |
| 213 | + # Read the triggering comment body and author from the saved artifact |
| 214 | + TRIGGER_COMMENT_BODY=$(jq -r '.body // ""' /tmp/feedback/feedback.json) |
| 215 | + TRIGGER_COMMENT_AUTHOR=$(jq -r '.user.login // ""' /tmp/feedback/feedback.json) |
| 216 | +
|
| 217 | + if [ -z "$TRIGGER_COMMENT_BODY" ]; then |
| 218 | + echo "::error::Triggering comment body is empty or missing" |
| 219 | + exit 1 |
| 220 | + fi |
| 221 | +
|
| 222 | + # Fetch the root comment |
| 223 | + root=$(gh api "repos/$REPO/pulls/comments/$ROOT_ID") || { |
| 224 | + echo "::error::Failed to fetch root comment $ROOT_ID" >&2 |
| 225 | + exit 1 |
| 226 | + } |
| 227 | + root_body=$(echo "$root" | jq -r '.body // ""') |
| 228 | +
|
| 229 | + # Fetch all review comments on this PR and filter to this thread |
| 230 | + all_comments=$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/comments" | \ |
| 231 | + jq -s --arg root_id "$ROOT_ID" \ |
| 232 | + '[.[][] | select(.in_reply_to_id == ($root_id | tonumber))] | sort_by(.created_at)') || { |
| 233 | + echo "::error::Failed to fetch thread comments for PR $PR_NUMBER" >&2 |
| 234 | + exit 1 |
| 235 | + } |
| 236 | +
|
| 237 | + # Build the thread context |
| 238 | + DELIM="THREAD_CONTEXT_$(openssl rand -hex 8)" |
| 239 | +
|
| 240 | + { |
| 241 | + echo "prompt<<$DELIM" |
| 242 | + echo "A developer replied to your review comment. Read the thread context below and respond" |
| 243 | + echo "in the same thread." |
| 244 | + echo "" |
| 245 | + echo "---" |
| 246 | + echo "REPO=$REPO" |
| 247 | + echo "PR_NUMBER=$PR_NUMBER" |
| 248 | + echo "ROOT_COMMENT_ID=$ROOT_ID" |
| 249 | + echo "FILE_PATH=$FILE_PATH" |
| 250 | + echo "LINE=$LINE" |
| 251 | + echo "" |
| 252 | + echo "[ORIGINAL REVIEW COMMENT]" |
| 253 | + echo "$root_body" |
| 254 | + echo "" |
| 255 | +
|
| 256 | + reply_count=$(echo "$all_comments" | jq 'length') |
| 257 | + if [ "$reply_count" -gt 0 ]; then |
| 258 | + for i in $(seq 0 $((reply_count - 1))); do |
| 259 | + comment_id=$(echo "$all_comments" | jq -r ".[$i].id") || continue |
| 260 | + # Skip the triggering comment — we append it from the artifact below |
| 261 | + if [ "$comment_id" = "$TRIGGER_COMMENT_ID" ]; then |
| 262 | + continue |
| 263 | + fi |
| 264 | + user_type=$(echo "$all_comments" | jq -r ".[$i].user.type") || continue |
| 265 | + author=$(echo "$all_comments" | jq -r ".[$i].user.login") || continue |
| 266 | + body=$(echo "$all_comments" | jq -r ".[$i].body") || continue |
| 267 | + if [ "$user_type" = "Bot" ]; then |
| 268 | + echo "[YOUR PREVIOUS REPLY by @$author]" |
| 269 | + else |
| 270 | + echo "[REPLY by @$author]" |
| 271 | + fi |
| 272 | + echo "$body" |
| 273 | + echo "" |
| 274 | + done |
| 275 | + fi |
| 276 | +
|
| 277 | + echo "[REPLY by @$TRIGGER_COMMENT_AUTHOR] ← this is the reply you are responding to" |
| 278 | + echo "$TRIGGER_COMMENT_BODY" |
| 279 | + echo "" |
| 280 | + echo "$DELIM" |
| 281 | + } >> $GITHUB_OUTPUT |
| 282 | +
|
| 283 | + echo "✅ Built thread context with replies" |
| 284 | +
|
| 285 | + # ---------------------------------------------------------------- |
| 286 | + # Checkout and run reply agent |
| 287 | + # ---------------------------------------------------------------- |
| 288 | + - name: Checkout PR head |
| 289 | + if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true' |
| 290 | + id: checkout |
| 291 | + continue-on-error: true |
| 292 | + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| 293 | + with: |
| 294 | + fetch-depth: 0 |
| 295 | + ref: refs/pull/${{ steps.meta.outputs.pr_number }}/head |
| 296 | + |
| 297 | + - name: Generate GitHub App token |
| 298 | + if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true' && env.HAS_APP_SECRETS == 'true' |
| 299 | + id: app-token |
| 300 | + continue-on-error: true |
| 301 | + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 |
| 302 | + with: |
| 303 | + app_id: ${{ secrets.CAGENT_REVIEWER_APP_ID }} |
| 304 | + private_key: ${{ secrets.CAGENT_REVIEWER_APP_PRIVATE_KEY }} |
| 305 | + |
| 306 | + - name: Run reply |
| 307 | + if: steps.meta.outputs.proceed == 'true' && steps.auth.outputs.authorized == 'true' && steps.checkout.outcome == 'success' && steps.thread.outcome == 'success' |
| 308 | + id: run-reply |
| 309 | + continue-on-error: true |
| 310 | + uses: ./review-pr/reply |
| 311 | + with: |
| 312 | + thread-context: ${{ steps.thread.outputs.prompt }} |
| 313 | + comment-id: ${{ steps.meta.outputs.comment_id }} |
| 314 | + anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} |
| 315 | + openai-api-key: ${{ secrets.OPENAI_API_KEY }} |
| 316 | + google-api-key: ${{ secrets.GOOGLE_API_KEY }} |
| 317 | + aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} |
| 318 | + xai-api-key: ${{ secrets.XAI_API_KEY }} |
| 319 | + nebius-api-key: ${{ secrets.NEBIUS_API_KEY }} |
| 320 | + mistral-api-key: ${{ secrets.MISTRAL_API_KEY }} |
| 321 | + github-token: ${{ steps.app-token.outputs.token || github.token }} |
| 322 | + |
| 323 | + # ---------------------------------------------------------------- |
| 324 | + # Failure handling |
| 325 | + # ---------------------------------------------------------------- |
| 326 | + - name: React on failure |
| 327 | + if: >- |
| 328 | + always() && |
| 329 | + steps.meta.outputs.proceed == 'true' && |
| 330 | + steps.auth.outputs.authorized == 'true' && |
| 331 | + (steps.checkout.outcome == 'failure' || steps.thread.outcome == 'failure' || steps.run-reply.outcome == 'failure') |
| 332 | + continue-on-error: true |
| 333 | + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 |
| 334 | + env: |
| 335 | + COMMENT_ID: ${{ steps.meta.outputs.comment_id }} |
| 336 | + REPO: ${{ steps.meta.outputs.repo }} |
| 337 | + with: |
| 338 | + github-token: ${{ steps.app-token.outputs.token || github.token }} |
| 339 | + script: | |
| 340 | + const [owner, repo] = process.env.REPO.split('/'); |
| 341 | + await github.rest.reactions.createForPullRequestReviewComment({ |
| 342 | + owner, |
| 343 | + repo, |
| 344 | + comment_id: parseInt(process.env.COMMENT_ID, 10), |
| 345 | + content: 'confused' |
| 346 | + }); |
| 347 | + console.log('😕 Reply failed — added confused reaction'); |
0 commit comments