diff --git a/.github/workflows/agents-pr-health.yml b/.github/workflows/agents-pr-health.yml index 9c00e879..b4e8eb6e 100644 --- a/.github/workflows/agents-pr-health.yml +++ b/.github/workflows/agents-pr-health.yml @@ -54,8 +54,8 @@ jobs: health: uses: stranske/Workflows/.github/workflows/reusable-agents-pr-health.yml@main with: - dry_run: ${{ inputs.dry_run || false }} - max_prs: ${{ inputs.max_prs || 10 }} - cron_interval_hours: ${{ vars.PR_HEALTH_INTERVAL_HOURS || 1 }} - is_scheduled_trigger: ${{ github.event_name == 'schedule' }} + dry_run: ${{ inputs.dry_run && 'true' || 'false' }} + max_prs: ${{ inputs.max_prs || '10' }} + cron_interval_hours: ${{ vars.PR_HEALTH_INTERVAL_HOURS || '1' }} + is_scheduled_trigger: ${{ github.event_name == 'schedule' && 'true' || 'false' }} secrets: inherit diff --git a/.github/workflows/maint-76-claude-code-review.yml b/.github/workflows/maint-76-claude-code-review.yml new file mode 100644 index 00000000..2a2d9c52 --- /dev/null +++ b/.github/workflows/maint-76-claude-code-review.yml @@ -0,0 +1,238 @@ +name: Claude Code Review (Opt-in) + +'on': + workflow_dispatch: + inputs: + pr_number: + description: 'Pull request number to review' + required: true + type: string + pull_request: + types: [labeled, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +jobs: + resolve-target: + name: Resolve review target + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.resolve.outputs.should_run }} + pr_number: ${{ steps.resolve.outputs.pr_number }} + reason: ${{ steps.resolve.outputs.reason }} + steps: + - id: resolve + uses: actions/github-script@v8 + with: + script: | + const eventName = context.eventName; + let shouldRun = "false"; + let prNumber = ""; + let reason = "not_opted_in"; + + if (eventName === "workflow_dispatch") { + const raw = `${core.getInput("pr_number") || ""}`.trim(); + if (!/^\d+$/.test(raw)) { + reason = "invalid_pr_number"; + } else { + const pull_number = Number(raw); + const { owner, repo } = context.repo; + try { + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number, + }); + prNumber = String(pull_number); + if (pr.state !== "open") { + reason = "pr_not_open"; + } else if (pr.draft) { + reason = "pr_is_draft"; + } else { + shouldRun = "true"; + reason = "manual_dispatch"; + } + } catch (error) { + reason = "pr_not_found"; + core.warning(`Unable to load PR #${pull_number}: ${error.message}`); + } + } + } else if (eventName === "pull_request") { + const pr = context.payload.pull_request; + prNumber = String(pr.number); + const labels = (pr.labels || []).map((label) => label.name); + if (labels.includes("claude-review")) { + shouldRun = "true"; + reason = "label_opt_in"; + } + } else { + reason = "unsupported_event"; + } + + core.setOutput("should_run", shouldRun); + core.setOutput("pr_number", prNumber); + core.setOutput("reason", reason); + + detect-secret: + name: Detect Claude token + needs: resolve-target + if: ${{ needs.resolve-target.outputs.should_run == 'true' }} + runs-on: ubuntu-latest + outputs: + has_token: ${{ steps.check.outputs.has_token }} + token_reason: ${{ steps.check.outputs.token_reason }} + workflow_unchanged: ${{ steps.workflow-integrity.outputs.workflow_unchanged }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - id: workflow-integrity + name: Check workflow matches base + run: | + set -euo pipefail + + base_ref="${{ github.base_ref }}" + if [ -z "$base_ref" ]; then + echo "workflow_unchanged=true" >>"$GITHUB_OUTPUT" + exit 0 + fi + + git fetch --no-tags origin \ + "refs/heads/${base_ref}:refs/remotes/origin/${base_ref}" + + set +e + diff_output="$(git diff --name-only "origin/${base_ref}"...HEAD -- \ + .github/workflows/maint-76-claude-code-review.yml)" + diff_ec=$? + set -e + + if [ "$diff_ec" -gt 1 ]; then + echo "workflow_unchanged=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + if [ -n "$diff_output" ]; then + echo "workflow_unchanged=false" >>"$GITHUB_OUTPUT" + else + echo "workflow_unchanged=true" >>"$GITHUB_OUTPUT" + fi + + - id: check + env: + CLAUDE_CODE_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + run: | + if [ -n "${CLAUDE_CODE_TOKEN}" ]; then + { + echo "has_token=true" + echo "token_reason=configured" + } >>"$GITHUB_OUTPUT" + else + { + echo "has_token=false" + echo "token_reason=missing" + } >>"$GITHUB_OUTPUT" + fi + + claude-review: + name: Run Claude Code Review + needs: [resolve-target, detect-secret] + if: >- + ${{ + needs.resolve-target.outputs.should_run == 'true' && + needs.detect-secret.outputs.has_token == 'true' && + needs.detect-secret.outputs.workflow_unchanged == 'true' + }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude + continue-on-error: true + uses: anthropics/claude-code-action@220272d38887a1caed373da96a9ffdb0919c26cc + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + allowed_bots: '*' + claude_args: | + --max-turns 8 + plugin_marketplaces: https://github.com/anthropics/claude-code.git + plugins: code-review@claude-code-plugins + prompt: >- + /code-review:code-review + ${{ github.repository }}/pull/${{ needs.resolve-target.outputs.pr_number }} + + - name: Record review failure (non-blocking) + if: steps.claude.outcome == 'failure' + run: | + cat <<'EOF' >>"$GITHUB_STEP_SUMMARY" + ### Claude Code Review failed (non-blocking) + The Claude review action failed to run for this PR. + Fix the token/config if automated reviews are required. + EOF + + skipped-not-opted-in: + name: Skip review (not opted in) + needs: resolve-target + if: ${{ needs.resolve-target.outputs.should_run != 'true' }} + runs-on: ubuntu-latest + steps: + - name: Explain opt-in behavior + run: | + cat <<'EOF' >>"$GITHUB_STEP_SUMMARY" + ### Claude Code Review skipped + This workflow is opt-in. + + To run automatically for a PR, add the `claude-review` label. + To run on demand, use workflow dispatch and provide `pr_number`. + EOF + + skipped-missing-secret: + name: Skip review (secret missing) + needs: [resolve-target, detect-secret] + if: >- + ${{ + needs.resolve-target.outputs.should_run == 'true' && + needs.detect-secret.outputs.token_reason == 'missing' + }} + runs-on: ubuntu-latest + steps: + - name: Explain missing secret + run: | + cat <<'EOF' >>"$GITHUB_STEP_SUMMARY" + ### Claude Code Review skipped + The `CLAUDE_CODE_OAUTH_TOKEN` secret is not configured. + Add it to enable automated reviews. + EOF + + skipped-workflow-modified: + name: Skip review (workflow modified) + needs: [resolve-target, detect-secret] + if: >- + ${{ + needs.resolve-target.outputs.should_run == 'true' && + needs.detect-secret.outputs.workflow_unchanged != 'true' + }} + runs-on: ubuntu-latest + steps: + - name: Explain workflow integrity guard + run: | + cat <<'EOF' >>"$GITHUB_STEP_SUMMARY" + ### Claude Code Review skipped + This workflow was modified in the PR. + The Claude review action requires the workflow to match the default + branch to safely access secrets. + EOF