|
| 1 | +name: Reusable Workflow - Claude Manual PR Review |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_call: |
| 5 | + inputs: |
| 6 | + pr_number: |
| 7 | + description: Pull request number to review. |
| 8 | + required: true |
| 9 | + type: number |
| 10 | + force_review: |
| 11 | + description: Run review even when the PR is below size thresholds. |
| 12 | + required: false |
| 13 | + default: false |
| 14 | + type: boolean |
| 15 | + allowed_actors: |
| 16 | + description: Comma-separated dispatcher allowlist. |
| 17 | + required: true |
| 18 | + type: string |
| 19 | + azure_client_id: |
| 20 | + description: Azure OIDC application client ID. |
| 21 | + required: true |
| 22 | + type: string |
| 23 | + azure_tenant_id: |
| 24 | + description: Azure tenant ID. |
| 25 | + required: true |
| 26 | + type: string |
| 27 | + azure_subscription_id: |
| 28 | + description: Azure subscription ID. |
| 29 | + required: true |
| 30 | + type: string |
| 31 | + azure_key_vault_name: |
| 32 | + description: Azure Key Vault name. |
| 33 | + required: true |
| 34 | + type: string |
| 35 | + claude_secret_name: |
| 36 | + description: Key Vault secret name that stores Claude OAuth token. |
| 37 | + required: true |
| 38 | + type: string |
| 39 | + min_changed_files: |
| 40 | + description: Minimum changed files threshold before auto-skip. |
| 41 | + required: false |
| 42 | + default: 5 |
| 43 | + type: number |
| 44 | + min_total_changes: |
| 45 | + description: Minimum total additions+deletions threshold before auto-skip. |
| 46 | + required: false |
| 47 | + default: 20 |
| 48 | + type: number |
| 49 | + |
| 50 | +permissions: |
| 51 | + contents: read |
| 52 | + |
| 53 | +concurrency: |
| 54 | + group: claude-manual-review-${{ inputs.pr_number }} |
| 55 | + cancel-in-progress: true |
| 56 | + |
| 57 | +jobs: |
| 58 | + claude-review: |
| 59 | + name: Claude Manual Review |
| 60 | + runs-on: ubuntu-latest |
| 61 | + permissions: |
| 62 | + contents: read |
| 63 | + pull-requests: write |
| 64 | + issues: write |
| 65 | + id-token: write |
| 66 | + |
| 67 | + steps: |
| 68 | + - name: Enforce default branch dispatch |
| 69 | + env: |
| 70 | + REF_NAME: ${{ github.ref_name }} |
| 71 | + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} |
| 72 | + run: | |
| 73 | + set -euo pipefail |
| 74 | + if [ "${REF_NAME}" != "${DEFAULT_BRANCH}" ]; then |
| 75 | + echo "Manual reviews are only allowed from ${DEFAULT_BRANCH}. Current ref: ${REF_NAME}" |
| 76 | + exit 1 |
| 77 | + fi |
| 78 | +
|
| 79 | + - name: Authorize dispatcher (allowlist) |
| 80 | + env: |
| 81 | + ACTOR: ${{ github.actor }} |
| 82 | + ALLOWED_ACTORS: ${{ inputs.allowed_actors }} |
| 83 | + run: | |
| 84 | + set -euo pipefail |
| 85 | +
|
| 86 | + if [ -z "${ALLOWED_ACTORS}" ]; then |
| 87 | + echo "Missing required allowlist input: allowed_actors" |
| 88 | + exit 1 |
| 89 | + fi |
| 90 | +
|
| 91 | + allowed="false" |
| 92 | + IFS=',' read -r -a actors <<< "${ALLOWED_ACTORS}" |
| 93 | + for raw_actor in "${actors[@]}"; do |
| 94 | + candidate="$(echo "${raw_actor}" | xargs)" |
| 95 | + if [ -n "${candidate}" ] && [ "${candidate}" = "${ACTOR}" ]; then |
| 96 | + allowed="true" |
| 97 | + break |
| 98 | + fi |
| 99 | + done |
| 100 | +
|
| 101 | + if [ "${allowed}" != "true" ]; then |
| 102 | + echo "Actor '${ACTOR}' is not authorized to run this workflow." |
| 103 | + exit 1 |
| 104 | + fi |
| 105 | +
|
| 106 | + - name: Login to Azure with OIDC |
| 107 | + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 |
| 108 | + with: |
| 109 | + client-id: ${{ inputs.azure_client_id }} |
| 110 | + tenant-id: ${{ inputs.azure_tenant_id }} |
| 111 | + subscription-id: ${{ inputs.azure_subscription_id }} |
| 112 | + |
| 113 | + - name: Fetch Claude OAuth token from Azure Key Vault |
| 114 | + id: keyvault |
| 115 | + env: |
| 116 | + AZURE_KEY_VAULT_NAME: ${{ inputs.azure_key_vault_name }} |
| 117 | + CLAUDE_SECRET_NAME: ${{ inputs.claude_secret_name }} |
| 118 | + run: | |
| 119 | + set -euo pipefail |
| 120 | +
|
| 121 | + claude_token="$(az keyvault secret show --vault-name "${AZURE_KEY_VAULT_NAME}" --name "${CLAUDE_SECRET_NAME}" --query value -o tsv)" |
| 122 | + if [ -z "${claude_token}" ]; then |
| 123 | + echo "Failed to read Claude token from Azure Key Vault secret '${CLAUDE_SECRET_NAME}'." |
| 124 | + exit 1 |
| 125 | + fi |
| 126 | +
|
| 127 | + echo "::add-mask::${claude_token}" |
| 128 | + echo "claude_code_oauth_token=${claude_token}" >> "${GITHUB_OUTPUT}" |
| 129 | +
|
| 130 | + - name: Resolve pull request metadata |
| 131 | + id: pr |
| 132 | + env: |
| 133 | + GH_TOKEN: ${{ github.token }} |
| 134 | + PR_NUMBER: ${{ inputs.pr_number }} |
| 135 | + run: | |
| 136 | + set -euo pipefail |
| 137 | +
|
| 138 | + if ! [[ "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then |
| 139 | + echo "Invalid pr_number input: '${PR_NUMBER}'. Expected a numeric pull request number." |
| 140 | + exit 1 |
| 141 | + fi |
| 142 | +
|
| 143 | + pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" |
| 144 | + head_sha="$(echo "${pr_json}" | jq -r '.head.sha')" |
| 145 | + title="$(echo "${pr_json}" | jq -r '.title')" |
| 146 | + body="$(echo "${pr_json}" | jq -r '.body // ""')" |
| 147 | + changed_files="$(echo "${pr_json}" | jq -r '.changed_files')" |
| 148 | + additions="$(echo "${pr_json}" | jq -r '.additions')" |
| 149 | + deletions="$(echo "${pr_json}" | jq -r '.deletions')" |
| 150 | + total_changes="$((additions + deletions))" |
| 151 | +
|
| 152 | + echo "head_sha=${head_sha}" >> "${GITHUB_OUTPUT}" |
| 153 | + echo "changed_files=${changed_files}" >> "${GITHUB_OUTPUT}" |
| 154 | + echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}" |
| 155 | +
|
| 156 | + title_delim="TITLE_$(cat /proc/sys/kernel/random/uuid)" |
| 157 | + { |
| 158 | + echo "title<<${title_delim}" |
| 159 | + echo "${title}" |
| 160 | + echo "${title_delim}" |
| 161 | + } >> "${GITHUB_OUTPUT}" |
| 162 | +
|
| 163 | + body_delim="BODY_$(cat /proc/sys/kernel/random/uuid)" |
| 164 | + { |
| 165 | + echo "body<<${body_delim}" |
| 166 | + echo "${body}" |
| 167 | + echo "${body_delim}" |
| 168 | + } >> "${GITHUB_OUTPUT}" |
| 169 | +
|
| 170 | + - name: Skip tiny pull requests unless forced |
| 171 | + if: ${{ !inputs.force_review && fromJSON(steps.pr.outputs.changed_files) < inputs.min_changed_files && fromJSON(steps.pr.outputs.total_changes) < inputs.min_total_changes }} |
| 172 | + run: | |
| 173 | + echo "Skipping Claude review because PR is below size thresholds." |
| 174 | + echo "Re-run with force_review=true to review this PR." |
| 175 | +
|
| 176 | + - name: Checkout pull request head |
| 177 | + if: ${{ inputs.force_review || fromJSON(steps.pr.outputs.changed_files) >= inputs.min_changed_files || fromJSON(steps.pr.outputs.total_changes) >= inputs.min_total_changes }} |
| 178 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 |
| 179 | + with: |
| 180 | + ref: ${{ steps.pr.outputs.head_sha }} |
| 181 | + fetch-depth: 1 |
| 182 | + persist-credentials: false |
| 183 | + |
| 184 | + - name: Run Claude manual pull request review |
| 185 | + id: claude_review |
| 186 | + if: ${{ inputs.force_review || fromJSON(steps.pr.outputs.changed_files) >= inputs.min_changed_files || fromJSON(steps.pr.outputs.total_changes) >= inputs.min_total_changes }} |
| 187 | + continue-on-error: true |
| 188 | + uses: anthropics/claude-code-action@c22f7c3f9dbdf2faa98a4c3139f7ec9eb5a691dc |
| 189 | + with: |
| 190 | + claude_code_oauth_token: ${{ steps.keyvault.outputs.claude_code_oauth_token }} |
| 191 | + github_token: ${{ github.token }} |
| 192 | + allowed_non_write_users: ${{ github.actor }} |
| 193 | + prompt: | |
| 194 | + REPO: ${{ github.repository }} |
| 195 | + PR NUMBER: ${{ inputs.pr_number }} |
| 196 | + PR TITLE: ${{ steps.pr.outputs.title }} |
| 197 | + PR BODY: |
| 198 | + ${{ steps.pr.outputs.body }} |
| 199 | +
|
| 200 | + You are performing a strict pull request review. Review only changed files in this PR. |
| 201 | + Post exactly one consolidated `gh pr comment`. |
| 202 | +
|
| 203 | + Required comment format: |
| 204 | + - first line: `Model used: anthropics/claude-code-action` |
| 205 | + - section `### Executive Summary` |
| 206 | + - section `### Findings` |
| 207 | + - section `### Test Coverage Gaps` |
| 208 | + - section `### Final Verdict` |
| 209 | +
|
| 210 | + Findings requirements: |
| 211 | + - prioritize by severity: Critical, High, Medium, Low |
| 212 | + - include concrete evidence with `path:line` references from the diff |
| 213 | + - classify each finding as one of: Bug, Security, Performance, Testing, Maintainability |
| 214 | + - explain why it matters and provide a concrete fix |
| 215 | + - avoid speculative findings without evidence |
| 216 | +
|
| 217 | + If no material issues exist, state exactly `No material issues found` and list any residual risks. |
| 218 | + Review only; do not modify files, push commits, or open additional PRs. |
| 219 | + Keep comments factual, specific, and action-oriented. |
| 220 | +
|
| 221 | + - name: Warn when Claude review fails (non-blocking) |
| 222 | + if: ${{ always() && (inputs.force_review || fromJSON(steps.pr.outputs.changed_files) >= inputs.min_changed_files || fromJSON(steps.pr.outputs.total_changes) >= inputs.min_total_changes) && steps.claude_review.outcome == 'failure' }} |
| 223 | + env: |
| 224 | + PR_NUMBER: ${{ inputs.pr_number }} |
| 225 | + run: | |
| 226 | + echo "::warning::Claude manual review failed but is configured as non-blocking. Check the previous step logs." |
| 227 | + { |
| 228 | + echo "### Claude Manual Review Warning" |
| 229 | + echo "- PR: #${PR_NUMBER}" |
| 230 | + echo "- Model action: anthropics/claude-code-action" |
| 231 | + echo "- Status: failed (non-blocking via continue-on-error)" |
| 232 | + } >> "${GITHUB_STEP_SUMMARY}" |
0 commit comments