diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index e827682e..458a26a5 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -1,11 +1,17 @@ name: Claude Issue Triage # Fires the Claude Code triage routine's /fire endpoint when a new issue -# opens OR when a repo member invokes `/triage` via comment (dispatched by +# opens, when a comment lands on an issue OR PR, or when a repo member +# invokes `/triage` via comment (dispatched by # `.github/workflows/slash-command-dispatch.yml`). # -# The issue body is fetched fresh and passed as *data* (fenced, size-capped) — -# the routine's prompt treats anything inside the fence as untrusted content. +# Both issue comments and PR comments are routed to the same routine; the +# payload's `is_pr` flag and `pr` block tell the routine which context it's +# in so it can pick the right response (issue triage vs PR-feedback fix). +# +# The issue/PR body is fetched fresh and passed as *data* (fenced, size- +# capped) — the routine's prompt treats anything inside the fence as +# untrusted content. # # Required repo secrets: # CLAUDE_ROUTINE_TRIAGE_URL — full /fire URL including routine ID @@ -35,14 +41,14 @@ jobs: name: Fire triage routine runs-on: ubuntu-latest timeout-minutes: 3 - # Skip bot-opened issues. Manual path already gated by slash-dispatch - # permission check. - # Three trigger paths, each gated: + # Three trigger paths, each with its own gate: # - issues.opened/reopened → skip bot-opened issues - # - issue_comment.created → skip bots, skip routine self-loops - # ("Triaged by Claude Code" footer), - # skip /triage (handled by repo_dispatch), - # skip PR conversations (auto-fix's job) + # - issue_comment.created → skip bots, skip self-loop (routine's + # own comments contain "Triaged by + # Claude Code"), skip /triage (handled + # by slash-command-dispatch path). + # Both issue and PR comments fire — the + # routine receives `is_pr` to branch on. # - repository_dispatch → always allow (slash-dispatch already # gated by member-association check) if: >- @@ -59,7 +65,7 @@ jobs: github.event.sender.type != 'Bot' && !startsWith(github.event.comment.body, '/triage') && !contains(github.event.comment.body, 'Triaged by Claude Code') && - github.event.issue.pull_request == null + !contains(github.event.comment.body, 'Fixed by Claude Code') ) || github.event_name == 'repository_dispatch' defaults: @@ -122,9 +128,28 @@ jobs: assoc=$(echo "$issue" | jq -r '.author_association // "NONE"') labels=$(echo "$issue" | jq -c '[.labels[].name]') html_url=$(echo "$issue" | jq -r '.html_url') + # GitHub's issues API populates `pull_request` only when the issue + # is actually a PR. When present, fetch PR-specific fields so the + # routine can branch on context (head/base ref, draft status, etc.). + is_pr=$(echo "$issue" | jq -r 'if .pull_request then "true" else "false" end') body_safe=$(printf '%s' "$body" | tr -d '\000' | head -c 8192) + pr_block="" + if [ "$is_pr" = "true" ]; then + pr=$(gh api "repos/$REPO/pulls/$ISSUE_NUMBER") + pr_head=$(echo "$pr" | jq -r '.head.ref') + pr_base=$(echo "$pr" | jq -r '.base.ref') + pr_draft=$(echo "$pr" | jq -r '.draft') + pr_state=$(echo "$pr" | jq -r '.state') + pr_block=$(jq -n \ + --arg head "$pr_head" \ + --arg base "$pr_base" \ + --arg draft "$pr_draft" \ + --arg state "$pr_state" \ + '{head_ref: $head, base_ref: $base, draft: ($draft == "true"), state: $state}') + fi + # For comment-driven runs, fetch the specific comment so the routine # can act on it. The full issue body is also included so the routine # has the original context, not just the new prose. @@ -159,13 +184,23 @@ jobs: --arg comment_body "$comment_body_safe" \ --arg comment_author "$comment_author" \ --arg comment_assoc "$comment_assoc" \ + --arg is_pr "$is_pr" \ + --arg pr_block "$pr_block" \ '{text: ( "Event: " + $kind + "." + $action + "\n" + "Repo: " + $repo + "\n" + - "Issue: #" + $num + " \"" + $title + "\"\n" + + (if $is_pr == "true" then "PR" else "Issue" end) + + ": #" + $num + " \"" + $title + "\"\n" + "URL: " + $url + "\n" + "Author: @" + $author + " (association: " + $assoc + ")\n" + "Labels: " + ($labels | join(", ")) + "\n" + + (if $is_pr == "true" then + "is_pr: true\n" + + "pr: " + $pr_block + "\n" + + "MODE: PR-feedback. Treat new comment as actionable feedback on the PR diff. If the comment requests a fix, apply it as a follow-up commit on the PR's head branch (do not open a new PR). If the comment asks a question, answer it as a reply comment. If the comment is conversational with no action implied, post a short acknowledgement and stop.\n" + else + "is_pr: false\n" + end) + (if $nudge == "" then "" else $nudge + "\n" end) + (if $comment_body == "" then "" else "\nNew comment by @" + $comment_author +