From 641609bdc09fe6c319a30b319e30515356dd5fa7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 11:21:00 +0100 Subject: [PATCH] feat: escalate unposted findings to issues and record wrap-up failures in the step summary Add escalate_findings_to_issues (review mode, off by default): when the turn-limit wrap-up step fails to post its own closing reply, a narrowly scoped follow-on judges each Blocker/Should-fix finding and opens or updates one issue per finding, deduped by a stable marker, rather than bundling every finding from a run into a single catch-all issue. Its allowlist holds only issue read/write tools, nothing the wrap-up call itself could already do. Requires issues: write, which review mode's reusable workflow does not request, so this only takes effect through the direct form (examples/direct/claude-review-with-issue-escalation.yml). Independent of that input, always write the wrap-up's own last result text to the job's Step Summary when the wrap-up step fails. This needs no token scope at all, so it fires for every consumer regardless of escalate_findings_to_issues, and it previously left that content visible only as raw JSON in a job log nobody reads by default. A wrap-up failure that escalation successfully turns into a recorded issue is treated as handled rather than as an unseen failure, so it no longer flips the job to failure on its own. --- README.md | 28 +++++-- action.yml | 82 ++++++++++++++++++- .../claude-review-with-issue-escalation.yml | 43 ++++++++++ 3 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 examples/direct/claude-review-with-issue-escalation.yml diff --git a/README.md b/README.md index ae51e68..1b31ecc 100644 --- a/README.md +++ b/README.md @@ -149,12 +149,12 @@ The two methods get there differently. `pip` queries PyPI's own public release m Each of `review`/`triage`/`interactive` is a separate reusable workflow with its own hardcoded `permissions:` block. That separation exists because a GitHub Actions `permissions:` block cannot branch on an input value, so scoping each mode to least privilege requires separate entry points. `generic` has no reusable workflow of its own (see [Adopting it in a repository](#adopting-it-in-a-repository)) — its token scope is simply whatever the calling job's own token already has, since generic mode makes no GitHub API calls of its own. -| Mode | Token scopes | Tools | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| review | `contents: read`, `pull-requests: write`, `actions: read` (`contents: write` instead of `read` when [automatic fixes](#automatic-fixes) are enabled, direct form only) | Submit a formal review (inline comments plus a summary body); read the diff and the code. No file writes, no commits, no push, no merge — **unless** one of the opt-in automatic-fix inputs is on, which adds a separate follow-up call that can write files and commit, and one step that pushes that commit to the pull request's own branch. Still no merge, and never on a fork pull request. | -| triage | `contents: read`, `issues: write` | View, list, comment on, label, assign, and edit issues (including setting a native issue type, sub-issue, or blocker relationship, and — when `update_issue_body` is on — the issue's own body); read the code. No pull-request scope at all. | -| interactive | `contents: write`, `pull-requests: write`, `issues: write` | Read and edit files, commit to a new branch, push it, open a pull request, comment. Merging, closing, releases, secrets, and workflow edits are blocked by denylist and prompt, not by token scope -- see the security notes below. | -| generic | None of its own (whatever the calling job's own token already has) | Runs `prompt_file` verbatim with `--allowedTools ""` (`Write`/`Edit`/`NotebookEdit` denied explicitly too, as a defensive default) — no fragment composition, no GitHub posting. For a caller that has already built its own prompt and wants Claude Code purely as a text/JSON generator; see [`prompt_file`](#extended-options) and [`examples/direct/claude-generic.yml`](examples/direct/claude-generic.yml). | +| Mode | Token scopes | Tools | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| review | `contents: read`, `pull-requests: write`, `actions: read` (`contents: write` instead of `read` when [automatic fixes](#automatic-fixes) are enabled, direct form only; plus `issues: write` when [`escalate_findings_to_issues`](#escalating-blocked-findings-to-issues) is enabled, also direct form only) | Submit a formal review (inline comments plus a summary body); read the diff and the code. No file writes, no commits, no push, no merge — **unless** one of the opt-in automatic-fix inputs is on, which adds a separate follow-up call that can write files and commit, and one step that pushes that commit to the pull request's own branch. Still no merge, and never on a fork pull request. | +| triage | `contents: read`, `issues: write` | View, list, comment on, label, assign, and edit issues (including setting a native issue type, sub-issue, or blocker relationship, and — when `update_issue_body` is on — the issue's own body); read the code. No pull-request scope at all. | +| interactive | `contents: write`, `pull-requests: write`, `issues: write` | Read and edit files, commit to a new branch, push it, open a pull request, comment. Merging, closing, releases, secrets, and workflow edits are blocked by denylist and prompt, not by token scope -- see the security notes below. | +| generic | None of its own (whatever the calling job's own token already has) | Runs `prompt_file` verbatim with `--allowedTools ""` (`Write`/`Edit`/`NotebookEdit` denied explicitly too, as a defensive default) — no fragment composition, no GitHub posting. For a caller that has already built its own prompt and wants Claude Code purely as a text/JSON generator; see [`prompt_file`](#extended-options) and [`examples/direct/claude-generic.yml`](examples/direct/claude-generic.yml). | `actions: read` on review is there solely so the optional `include_ci_logs` input can read failing workflow logs. Because a reusable workflow's permissions cannot be conditional, the scope is declared unconditionally even when the input is off. It is read-only and covers workflow run metadata and logs. If you want strictly the two scopes review actually uses, call the composite action directly with your own `permissions:` block instead of going through the reusable workflow. @@ -246,6 +246,20 @@ Three opt-in review-mode inputs — all off by default, and the largest widening See the [Security notes](#security-notes) above for what bounds this feature and what does not. +## Escalating blocked findings to issues + +The [turn-limit wrap-up](#extended-options) can itself fail — its own closing reply is often an attempt to post somewhere (a PR comment, a review), and that write can be blocked, or the pull request or issue it was about can have already merged or closed underneath it. Confirmed live: a review's wrap-up call correctly determined a pull request had already merged, re-verified every finding from its own prior review was in fact fixed in the merged code, and then failed outright trying to post that conclusion — `gh pr comment` and `mcp__github__add_issue_comment` were both blocked pending a permission grant no one was present to give in a headless run. Nothing this action already does surfaces that: the closing reply, which is exactly the content that should have been visible somewhere, existed only as raw JSON in a job log nobody reads by default, and the run showed a plain red ✗ with no indication of why. + +Two independent responses to this, at two different costs: + +**`Write wrap-up result to step summary` runs automatically whenever the wrap-up step fails, for every mode, with no opt-in and no extra permission.** Writing to `$GITHUB_STEP_SUMMARY` needs no token scope at all, so this always fires: it re-reads the wrap-up's own execution log, pulls out its last `result` text — whatever Claude was actually trying to say when the write it attempted got blocked — and appends it to the job's Step Summary, the tab everyone already looks at for a failed run. This alone would have surfaced the merged-pull-request case above without any further configuration. + +**`escalate_findings_to_issues`** (review mode only, off by default) goes further: a second, tightly scoped follow-on resumes the same session and asks Claude to judge, finding by finding, whether anything it already found is severe enough — Blocker or Should-fix, never a Nit, and never something it was only speculating about — to warrant a standing record even though its normal output channel is unavailable. For each finding that clears that bar, it searches open issues for one already carrying that finding's own stable `` marker and updates it, or opens a new issue if none exists — **one issue per distinct finding, never one bundled issue for the whole run**, since a single catch-all issue would mix unrelated concerns and make each one individually untrackable and un-closeable as fixes land. This call's own allowlist holds only issue read/write tools (`Bash(gh issue ...)`, `mcp__github__*_issue`, `mcp__github__search_issues`) — no PR mutation, no `gh api:*`, nothing the wrap-up call itself could already do, so a subverted wrap-up reply gains nothing from this feature existing; only this explicitly-scoped, explicitly-opted-into call can write an issue. + +**It only takes effect in the [direct form](examples/direct/claude-review-with-issue-escalation.yml), for the same reason `fix_ci_failures`/`fix_diff_findings` do.** It needs `issues: write`, which review mode's reusable workflow does not request in its own hardcoded `permissions:` block — and, per [step 3](#adopting-it-in-a-repository) above, a caller-side grant cannot widen a reusable workflow's job beyond what that job's own `permissions:` block already requests. Call `ExaDev/claude-code-action@v1` directly instead, with your own job `permissions:` including `issues: write`, alongside whatever `review` mode already needs. + +A wrap-up failure that escalation successfully turns into a recorded issue is no longer treated as an unseen job failure — it counts as handled, and the run is not marked failed on that basis alone. A wrap-up failure with `escalate_findings_to_issues` off, or one escalation itself could not act on, still fails the job exactly as before: the Step Summary write above is unconditional and costs nothing, but it is a record, not a resolution, and the run staying red is what makes a human go looking for that record in the first place. + ## Adding repository-local instructions To extend the prompts for one repository, without forking anything, add either or both of: @@ -332,7 +346,7 @@ Beyond the basics, every mode's reusable workflow exposes further optional input **Progress and comment behaviour** (`review`, `triage`, `interactive`): `track_progress` and `use_sticky_comment` are pure passthroughs to upstream's own tracking-comment mechanism, which only its `tag` mode implements — every mode this action runs always sets a `prompt` (see "Compose prompt" in `action.yml`), which upstream's `detectMode` treats as `agent` mode regardless of `track_progress`/`use_sticky_comment`, so **neither input has any visible effect through this action**; they are exposed only because they are harmless passthroughs, not because they do anything here. `post_progress_comment` (on by default) is the input that actually gets you a visible signal: it posts a plain "Claude is working on this" comment via `gh api`, entirely from this action's own steps rather than upstream, before the run starts, and edits that same comment once the run (and any optional follow-up pass) finishes. It needs no special token identity — just whatever `issues: write` / `pull-requests: write` scope the calling job already grants for its own mode — so it works in both the reusable-workflow and direct forms. Set it `false` to restore the previous silent-until-output behaviour. `include_comments_by_actor` / `exclude_comments_by_actor` filter whose comments feed into context (supports `*[bot]` wildcards) — worth setting `exclude_comments_by_actor` on `interactive` specifically, since that mode already treats comment text as untrusted input per the security notes above. `include_fix_links` (`review` only, on by default upstream) adds "fix this" links to findings. -**Review behaviour** (`review` only): `severity_ratings` controls emoji severity on findings (see [Severity labels and emoji ratings](#severity-labels-and-emoji-ratings)); `allowed_review_states` controls which review states the bot may submit — including whether it can approve (see [Review states](#review-states)); `include_suggestions` (on by default) controls whether review comments carry apply-able GitHub suggestion blocks for findings with a confident, drop-in fix — set `false` for prose-only reviews; `fix_pr_metadata` (on by default) checks whether the pull request's title and description are complete and accurate and rewrites them directly, grounded in the diff, when they fall short — the allowlist grants the whole `update_pull_request` tool, which can also close the pull request or change its base branch, and only the prompt restricts it to the title and body fields, so treat this as at least as sensitive as `resolve_stale_threads` below and set `false` on repositories that accept untrusted fork pull requests; unlike `resolve_stale_threads` it needs no special token identity and works in both the reusable-workflow and direct forms; `resolve_stale_threads` (on by default) resolves the bot's own prior review threads that a new revision has addressed, updates ones whose finding still holds but whose comment text has drifted out of sync with the diff, and dismisses the bot's own prior `CHANGES_REQUESTED` or `APPROVED` reviews on re-review regardless of what the new review's own verdict turns out to be — clearing each dismissed review's body first, since GitHub's dismissal only changes state and otherwise leaves the write-up fully visible on the timeline — so neither stale comments nor a stale blocking-or-approving verdict accumulate across pushes — **it only takes effect in the [direct form](examples/direct/claude-review.yml)**, which runs review as `claude[bot]` (the reusable form's `github-actions[bot]` is forbidden from resolving threads, so the step no-ops there); it also widens the review allowlist (adds `gh api:*`), so set `false` on repositories that accept untrusted fork pull requests; `verify_prior_findings` (on by default) re-checks a technical claim a past review made — whether an API exists, how it is meant to be used, what a version supports — against the actual package, lockfile, or vendored source, and against current documentation via `WebFetch` when that is not decisive on its own, before trusting that claim again on a re-review; unlike `resolve_stale_threads` it needs no special token identity and works in both the reusable-workflow and direct forms, but it grants unrestricted `WebFetch` — arbitrary network egress that nothing else in this allowlist grants — so set `false` on repositories that accept untrusted fork pull requests; `include_ci_logs` grants the CI-inspection tools so a review can read and mention this pull request's failing workflow logs (needs the calling job to hold `actions: read`); `classify_inline_comments` (on by default) buffers inline comments Claude hasn't marked `confirmed=true` and classifies each as a genuine finding or a test/probe before posting once the session ends, so low-value probe comments are suppressed from the final review — set `false` to restore immediate, unbuffered posting of every inline comment (upstream's pre-buffering behavior); `structured_review_summary` (off by default, unlike the toggles above) runs a bounded follow-up call after the review that re-fetches what was actually submitted and produces a `structured_output` JSON summary (`review_state`, and finding counts by `blocker`/`should_fix`/`nit`) for a caller to consume — it costs a second call on every review regardless of whether anything reads the output, which is why it defaults off rather than on; a schema-validation failure in that follow-up call only leaves `structured_output` empty, it cannot fail the review itself, which has already been submitted by the time the follow-up starts. +**Review behaviour** (`review` only): `severity_ratings` controls emoji severity on findings (see [Severity labels and emoji ratings](#severity-labels-and-emoji-ratings)); `allowed_review_states` controls which review states the bot may submit — including whether it can approve (see [Review states](#review-states)); `include_suggestions` (on by default) controls whether review comments carry apply-able GitHub suggestion blocks for findings with a confident, drop-in fix — set `false` for prose-only reviews; `fix_pr_metadata` (on by default) checks whether the pull request's title and description are complete and accurate and rewrites them directly, grounded in the diff, when they fall short — the allowlist grants the whole `update_pull_request` tool, which can also close the pull request or change its base branch, and only the prompt restricts it to the title and body fields, so treat this as at least as sensitive as `resolve_stale_threads` below and set `false` on repositories that accept untrusted fork pull requests; unlike `resolve_stale_threads` it needs no special token identity and works in both the reusable-workflow and direct forms; `resolve_stale_threads` (on by default) resolves the bot's own prior review threads that a new revision has addressed, updates ones whose finding still holds but whose comment text has drifted out of sync with the diff, and dismisses the bot's own prior `CHANGES_REQUESTED` or `APPROVED` reviews on re-review regardless of what the new review's own verdict turns out to be — clearing each dismissed review's body first, since GitHub's dismissal only changes state and otherwise leaves the write-up fully visible on the timeline — so neither stale comments nor a stale blocking-or-approving verdict accumulate across pushes — **it only takes effect in the [direct form](examples/direct/claude-review.yml)**, which runs review as `claude[bot]` (the reusable form's `github-actions[bot]` is forbidden from resolving threads, so the step no-ops there); it also widens the review allowlist (adds `gh api:*`), so set `false` on repositories that accept untrusted fork pull requests; `verify_prior_findings` (on by default) re-checks a technical claim a past review made — whether an API exists, how it is meant to be used, what a version supports — against the actual package, lockfile, or vendored source, and against current documentation via `WebFetch` when that is not decisive on its own, before trusting that claim again on a re-review; unlike `resolve_stale_threads` it needs no special token identity and works in both the reusable-workflow and direct forms, but it grants unrestricted `WebFetch` — arbitrary network egress that nothing else in this allowlist grants — so set `false` on repositories that accept untrusted fork pull requests; `include_ci_logs` grants the CI-inspection tools so a review can read and mention this pull request's failing workflow logs (needs the calling job to hold `actions: read`); `classify_inline_comments` (on by default) buffers inline comments Claude hasn't marked `confirmed=true` and classifies each as a genuine finding or a test/probe before posting once the session ends, so low-value probe comments are suppressed from the final review — set `false` to restore immediate, unbuffered posting of every inline comment (upstream's pre-buffering behavior); `structured_review_summary` (off by default, unlike the toggles above) runs a bounded follow-up call after the review that re-fetches what was actually submitted and produces a `structured_output` JSON summary (`review_state`, and finding counts by `blocker`/`should_fix`/`nit`) for a caller to consume — it costs a second call on every review regardless of whether anything reads the output, which is why it defaults off rather than on; a schema-validation failure in that follow-up call only leaves `structured_output` empty, it cannot fail the review itself, which has already been submitted by the time the follow-up starts; `escalate_findings_to_issues` (off by default) opens or updates a GitHub issue per finding when the turn-limit wrap-up fails to post its own closing reply — see [Escalating blocked findings to issues](#escalating-blocked-findings-to-issues) for what it does, its permission requirement, and the always-on, no-permission-needed Step Summary fallback that runs regardless of whether this is enabled. **Branch and commit behaviour** (`interactive` only — `review` and `triage` hold no write scope, so these are absent from those two workflows entirely, not just defaulted off): `base_branch`, `branch_prefix` (default `claude/`), and `branch_name_template` control how a fix branch is named and based. `use_commit_signing` turns on GitHub's own commit signature verification with no key management required — it's a plain boolean, off by default, nothing to provision. `ssh_signing_key` is a separate, optional **secret** (a real SSH private key) for signing commits yourself instead; it takes precedence over `use_commit_signing` if both are set. Neither is required — plain git commits are the default if you set neither. diff --git a/action.yml b/action.yml index 32e9b45..14c44c1 100644 --- a/action.yml +++ b/action.yml @@ -203,6 +203,11 @@ inputs: All modes. When the primary run hits its turn limit (cut off mid-task rather than finishing cleanly), fire a short follow-on run (3 turns) that resumes the same session and asks Claude to summarise what it found and what remains, so the work is not lost. On by default; set to "false" to skip the follow-on entirely. required: false default: "true" + escalate_findings_to_issues: + description: | + Review mode only. Off by default. When the turn-limit wrap-up (above) runs and itself fails -- most often because its own closing reply tried to post somewhere (a PR comment, a review) and that write was blocked or the pull request had already merged or closed underneath it -- fire a further, tightly scoped follow-on that resumes the same session and asks Claude to judge, finding by finding, whether what it already found is severe enough (Blocker or Should-fix; never a Nit) to warrant a standing record even though its normal output channel was unavailable. For each finding that clears that bar, it searches open issues for one already carrying that finding's own stable marker and updates it, or opens a new issue if none exists -- one issue per distinct finding, never one bundled issue for the whole run. This call's allowlist holds only issue read/write tools (Bash(gh issue ...), mcp__github__*_issue, mcp__github__search_issues) -- no PR mutation, no gh api:*, nothing the wrap-up call itself could already do. Requires the calling job to hold issues: write, which review mode's reusable workflow does not request and cannot be made to via a caller-side permissions: block (see "What each mode is allowed to do") -- this only takes effect when you call ExaDev/claude-code-action@v1 directly with your own job permissions: including issues: write, the same constraint fix_ci_failures/fix_diff_findings already have for contents: write. Independent of this input, the wrap-up's own last reply is always additionally written to the job's Step Summary when the wrap-up step fails, at no permission cost -- see "Write wrap-up result to step summary". + required: false + default: "false" severity_ratings: description: | Review mode only. Whether findings also carry an emoji severity rating (🔴 Blocker, 🟠 Should fix, 🟡 Nit) @@ -439,6 +444,7 @@ runs: ADD_REGRESSION_TESTS: ${{ inputs.add_regression_tests }} RESUME_SESSIONS: ${{ inputs.resume_sessions }} POST_PROGRESS_COMMENT: ${{ inputs.post_progress_comment }} + ESCALATE_FINDINGS_TO_ISSUES: ${{ inputs.escalate_findings_to_issues }} UPDATE_ISSUE_BODY: ${{ inputs.update_issue_body }} PROMPT_FILE: ${{ inputs.prompt_file }} HEADROOM_ENABLED: ${{ inputs.headroom_enabled }} @@ -571,9 +577,9 @@ runs: exit 1 fi - # These three default to false, unlike every other review toggle -- a true here can only be deliberate, so a non-review mode should be told rather than have it silently ignored. + # These four default to false, unlike every other review toggle -- a true here can only be deliberate, so a non-review mode should be told rather than have it silently ignored. if [ "$MODE" != "review" ]; then - for _flag_pair in "fix_ci_failures:${FIX_CI_FAILURES}" "fix_diff_findings:${FIX_DIFF_FINDINGS}" "add_regression_tests:${ADD_REGRESSION_TESTS}"; do + for _flag_pair in "fix_ci_failures:${FIX_CI_FAILURES}" "fix_diff_findings:${FIX_DIFF_FINDINGS}" "add_regression_tests:${ADD_REGRESSION_TESTS}" "escalate_findings_to_issues:${ESCALATE_FINDINGS_TO_ISSUES}"; do if [ "${_flag_pair#*:}" = "true" ]; then echo "::error::${_flag_pair%%:*} is a review-mode input and does nothing in '${MODE}' mode. It is off by default, so setting it can only be deliberate: remove it, or set mode: review." exit 1 @@ -597,6 +603,14 @@ runs: ;; esac + case "$ESCALATE_FINDINGS_TO_ISSUES" in + true|false) ;; + *) + echo "::error::escalate_findings_to_issues must be true or false. Got '${ESCALATE_FINDINGS_TO_ISSUES}'." + exit 1 + ;; + esac + case "$UPDATE_ISSUE_BODY" in true|false) ;; *) @@ -1863,6 +1877,62 @@ runs: path_to_claude_code_executable: ${{ inputs.path_to_claude_code_executable }} path_to_bun_executable: ${{ inputs.path_to_bun_executable }} + # --- Write wrap-up result to step summary --- The wrap-up call above can itself fail: its own closing reply is often an attempt to post somewhere (a PR comment, a review), and that write can be blocked (permission denial, a pull request that merged or closed underneath it) with nothing about the failure itself durable anywhere a human would think to look -- the wrap-up's own last reply, which is exactly the content that was supposed to be posted, otherwise exists only as raw JSON in a job log nobody reads by default. This costs no permission beyond what every job already has (writing to $GITHUB_STEP_SUMMARY needs no token scope at all), so it runs unconditionally on a wrap-up failure regardless of escalate_findings_to_issues. + - name: Write wrap-up result to step summary + id: wrap-up-summary + if: ${{ steps.wrap-up.outcome == 'failure' }} + continue-on-error: true + shell: bash + env: + EXEC_FILE: ${{ steps.wrap-up.outputs.execution_file }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + if [ -z "$EXEC_FILE" ] || [ ! -f "$EXEC_FILE" ]; then + echo "No wrap-up execution file to summarise." + exit 0 + fi + + # The stream-json log carries one JSON object per line; the last "result"-typed one is the closing reply -- the same field the earlier "OAuth access token is invalid" case and the later "PR already merged, here is what I verified" case both actually populate, whether the call itself succeeded or errored. + LAST_RESULT=$(jq -rs '[.[] | select(.type == "result") | .result] | last // empty' "$EXEC_FILE" 2>/dev/null || echo "") + if [ -z "$LAST_RESULT" ]; then + echo "Wrap-up execution file had no result text to summarise." + exit 0 + fi + + { + echo "### Claude's wrap-up could not be posted" + echo + echo "The turn-limit wrap-up run itself failed after producing this closing reply -- most likely because its own attempt to post it was blocked, or the pull request/issue it was about had already changed underneath it. Recorded here since nothing else guarantees it reaches anywhere durable. [View job run]($RUN_URL)" + echo + echo "$LAST_RESULT" + } >> "$GITHUB_STEP_SUMMARY" + echo "Wrote wrap-up result to the job's Step Summary." + + # --- Escalate blocked findings to issues --- Opt-in, off by default, review mode only: see escalate_findings_to_issues's own description for the permission constraint (issues: write, direct form only) and what this call is and is not allowed to do. Deliberately a separate, narrower-allowlisted follow-on rather than widening the wrap-up call's own allowlist: the wrap-up call above still cannot write anywhere by itself, so a subverted wrap-up reply gains nothing from this feature existing -- only this explicitly-scoped, explicitly-opted-into call can. + - name: Escalate blocked findings to issues + id: escalate + if: ${{ inputs.mode == 'review' && inputs.escalate_findings_to_issues == 'true' && steps.wrap-up.outcome == 'failure' }} + continue-on-error: true + uses: anthropics/claude-code-action@e8c2d7c16c018cf1e694711c1c07a5f5db2b5eb1 # v1.0.208 + with: + prompt: | + Your last reply in this session could not be posted where it was meant to go -- most likely because that write was blocked, or because the pull request had already merged or closed underneath you. This is a further, final pass: decide whether anything you already found is worth a standing record even though your normal output channel is unavailable. + + For each distinct finding you already made that is a Blocker or Should-fix (never a Nit, and never anything you were only speculating about) and that has not already been recorded anywhere you can see (check for a prior formal review, comment, or issue first): + + 1. Search open issues in this repository for one already carrying the marker ``. Use a slug you would produce identically if you analysed the same finding again -- not a random or timestamped one -- so a future run's search actually matches it. + 2. If a matching issue exists and is still open, add a comment noting this run also encountered it, rather than opening a duplicate. + 3. Otherwise, open a new issue: a title naming the actual defect, a body with the file/line, what is wrong, why it matters, and the marker from step 1, and a native issue type of Bug where this organisation has issue types configured. + + One issue per distinct finding -- never bundle unrelated findings into a single issue. If nothing you found clears the Blocker/Should-fix bar, or everything is already recorded somewhere, do nothing and say so; do not create an issue just to have created one. + claude_args: '--resume ${{ steps.claude.outputs.session_id }} --max-turns 10 --allowedTools "Read,Grep,Glob,Bash(gh issue list:*),Bash(gh issue view:*),Bash(gh issue create:*),Bash(gh issue comment:*),Bash(gh issue edit:*),mcp__github__search_issues,mcp__github__list_issues,mcp__github__get_issue,mcp__github__create_issue,mcp__github__update_issue,mcp__github__add_issue_comment" --disallowedTools "Write,Edit,NotebookEdit,Bash(git push:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh issue close:*),Bash(gh issue delete:*)"' + anthropic_api_key: ${{ steps.claude.outputs.winning_cred_type == 'key' && steps.claude.outputs.winning_cred_token || '' }} + claude_code_oauth_token: ${{ steps.claude.outputs.winning_cred_type == 'oauth' && steps.claude.outputs.winning_cred_token || '' }} + github_token: ${{ inputs.github_token }} + path_to_claude_code_executable: ${{ inputs.path_to_claude_code_executable }} + path_to_bun_executable: ${{ inputs.path_to_bun_executable }} + # --- Automatic fixes --- Opt-in, off by default: see fix_ci_failures/fix_diff_findings's own descriptions for why this is the largest widening of review's blast radius this action offers. The review call above is unchanged -- Write/Edit/NotebookEdit stay on its denylist -- so the thing that reads untrusted diff content and the thing that can write a file are different calls with different allowlists. This step is also the gate: it proves a real push is possible, with the credential a real push would use, before anything is paid for -- so the reusable-workflow form (contents: read, hardcoded, and a permissions block cannot branch on an input) and every fork pull request skip the whole feature for free, the same way resolve_stale_threads no-ops in the reusable form. Nothing the fix pass writes reaches GitHub unless "Publish applied fixes" below explicitly pushes it, from a fixed refspec no prompt can influence -- the model has no git push tool at all. See the README's "Automatic fixes" section. - name: Prepare fix pass id: fix-args @@ -2200,6 +2270,8 @@ runs: CLAUDE_OUTCOME: ${{ steps.claude.outcome }} # "Wrap up on turn limit" is the one later step with no continue-on-error and no prior id (unlike the fix pass / structured-summary steps, which are all continue-on-error: true and so can never make an already-successful primary run look failed here) -- if it runs and itself fails, the job outcome is failure even though steps.claude.outcome is still the earlier, successful "success". Folded in below rather than trusted on its own, since it reports "skipped" (not "success") whenever detect-turn-limit didn't fire, which must not be read as a failure. WRAP_UP_OUTCOME: ${{ steps.wrap-up.outcome }} + # A wrap-up failure that "Escalate blocked findings to issues" then actually recorded somewhere durable (a new or updated issue) is no longer an unseen failure -- it is a handled one, so it should not flip an otherwise-successful run to failure. escalate itself is continue-on-error: true and reports "skipped" whenever escalate_findings_to_issues is off or the wrap-up did not fail, both of which must not be read as "recovered" -- only a genuine "success" here counts. + ESCALATE_OUTCOME: ${{ steps.escalate.outcome }} # "Verify triage output landed" is the other step with no continue-on-error -- same reasoning, folded in the same way. It only ever runs (rather than reporting "skipped") for triage mode, so the "skipped" exemption below applies identically for every other mode. VERIFY_TRIAGE_OUTCOME: ${{ steps.verify-triage-output.outcome }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -2207,7 +2279,11 @@ runs: set -euo pipefail OUTCOME="$CLAUDE_OUTCOME" if [ "$OUTCOME" = "success" ] && [ -n "${WRAP_UP_OUTCOME:-}" ] && [ "$WRAP_UP_OUTCOME" != "success" ] && [ "$WRAP_UP_OUTCOME" != "skipped" ]; then - OUTCOME="$WRAP_UP_OUTCOME" + if [ "${ESCALATE_OUTCOME:-}" = "success" ]; then + echo "Wrap-up failed (outcome: ${WRAP_UP_OUTCOME}), but escalation recorded it as an issue -- not treating this run as failed." + else + OUTCOME="$WRAP_UP_OUTCOME" + fi fi if [ "$OUTCOME" = "success" ] && [ -n "${VERIFY_TRIAGE_OUTCOME:-}" ] && [ "$VERIFY_TRIAGE_OUTCOME" != "success" ] && [ "$VERIFY_TRIAGE_OUTCOME" != "skipped" ]; then OUTCOME="$VERIFY_TRIAGE_OUTCOME" diff --git a/examples/direct/claude-review-with-issue-escalation.yml b/examples/direct/claude-review-with-issue-escalation.yml new file mode 100644 index 0000000..de24e99 --- /dev/null +++ b/examples/direct/claude-review-with-issue-escalation.yml @@ -0,0 +1,43 @@ +# Composite-action-step (direct) form of claude-review.yml with escalate_findings_to_issues turned on. Copy this to .github/workflows/claude-review.yml in a repository in your organisation -- INSTEAD OF, not alongside, either of the other two review examples. +# +# What this adds over examples/direct/claude-review.yml: when the turn-limit wrap-up runs and itself fails -- typically because its own closing reply tried to post somewhere and that write was blocked, or the pull request it was about had already merged or closed underneath it -- a further, narrowly-scoped follow-on judges whether anything already found is severe enough (Blocker or Should-fix, never a Nit) to open or update a GitHub issue for, one issue per distinct finding. See the README's "Escalating blocked findings to issues" section for the full mechanism, what this call can and cannot do, and the always-on, no-permission-needed Step Summary fallback that records the same content regardless of whether this input is set. +# +# The only difference from examples/direct/claude-review.yml besides the flag itself: issues: write below. escalate_findings_to_issues needs it, review mode's reusable workflow does not request it, and a caller-side permissions: block cannot widen a reusable workflow's job beyond what that job's own permissions: block already requests -- so this only works through the direct form, exactly like fix_ci_failures/fix_diff_findings need contents: write via examples/direct/claude-review-with-fixes.yml. +# +# Requires the Claude Code GitHub App installed on the organisation (https://github.com/apps/claude) and a CLAUDE_CODE_OAUTH_TOKEN secret, exactly as examples/direct/claude-review.yml does. + +name: Claude Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write # escalate_findings_to_issues needs this; review mode does not request it otherwise + actions: read # only used when include_ci_logs is true; read-only, covers workflow-run metadata and logs + id-token: write # the OIDC exchange that mints the Claude Code App token needs this; the reusable-workflow form cannot grant it, which is why resolve_stale_threads only works here + +# Reviewing every push to a busy branch gets expensive. This cancels an in-flight review when the author pushes again. +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: review mode's local git diff/log/show tools compare against the base branch, which a shallow (depth-1) clone does not have. + fetch-depth: 0 + - uses: ExaDev/claude-code-action@v1 + with: + mode: review + # No github_token: leaving it unset is what makes the review run as the Claude Code App (claude[bot]). See examples/direct/claude-review.yml's header comment. + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + escalate_findings_to_issues: true + # Every other input is optional with a sensible default -- see action.yml for the full list.