| 1 | name: Codewhale PR Review |
| 2 | |
| 3 | # Advisory AI code review by Codewhale itself (`codewhale review --pr --post`) |
| 4 | # on every non-draft PR: one COMMENT review with a summary body plus inline |
| 5 | # line comments, anchored to the PR head SHA. CODEOWNERS (@Hmbown) stays the |
| 6 | # human owner — this review posts alongside it and never approves. |
| 7 | # |
| 8 | # Setup: docs/GITHUB_APP.md. CODEWHALE_API_KEY is an account machine key; |
| 9 | # it stays on the Codewhale relay and is never copied into a vendor variable. |
| 10 | # Account mode requires an explicit account-catalog provider/model id in |
| 11 | # CODEWHALE_REVIEW_MODEL. BYOK uses the provider's own key unchanged. |
| 12 | # |
| 13 | # Only same-repository PRs receive model/App secrets or execute the candidate |
| 14 | # build. Fork PRs fetch objects against a trusted base checkout, but do not run |
| 15 | # a model review. Keep this pull_request event: a PR must not gain secrets by |
| 16 | # being fetched for its diff. Same-repository authors already have write access. |
| 17 | # |
| 18 | # CODEWHALE_REVIEW_MAX_CHARS bounds each complete ordered review pass |
| 19 | # (normally 200000). CODEWHALE_REVIEW_MAX_PASSES normally defaults to 1. |
| 20 | # PR #6002 is configured for 500000-char / 16-pass / 65536-output |
| 21 | # DeepSeek Pro review below; other PRs retain the conservative defaults. |
| 22 | # Exceeding either coverage bound never posts a prefix. |
| 23 | # Missing keys and provider outages keep the existing advisory policy, and |
| 24 | # their explicit non-run receipts must never be counted as completed reviews. |
| 25 | |
| 26 | on: |
| 27 | pull_request: |
| 28 | types: [opened, synchronize, reopened, ready_for_review] |
| 29 | branches: [master, main] |
| 30 | |
| 31 | concurrency: |
| 32 | group: codewhale-review-${{ github.event.pull_request.number }} |
| 33 | cancel-in-progress: true |
| 34 | |
| 35 | jobs: |
| 36 | codewhale-review: |
| 37 | name: Codewhale review |
| 38 | if: github.event.pull_request.draft == false |
| 39 | runs-on: ubuntu-latest |
| 40 | env: |
| 41 | # `secrets` is unavailable in a job-level `if:` but allowed here; these |
| 42 | # are booleans about presence, never key material. |
| 43 | HAS_ANY_KEY: ${{ github.event.pull_request.head.repo.full_name == github.repository && (secrets.CODEWHALE_API_KEY != '' || secrets.ZAI_API_KEY != '' || secrets.MODELSTUDIO_API_KEY != '' || secrets.DEEPSEEK_API_KEY != '' || secrets.OPENROUTER_API_KEY != '' || secrets.ANTHROPIC_API_KEY != '') }} |
| 44 | HAS_APP_KEY: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY != '' }} |
| 45 | permissions: |
| 46 | contents: read |
| 47 | pull-requests: write |
| 48 | # `gh api .../issues/comments/{id}` PATCH/DELETE below is the issue-comment |
| 49 | # endpoint. Every call is `|| true` or `|| echo ::warning::`, so a missing |
| 50 | # permission would fail silently — the exact "non-run passes for a clean |
| 51 | # review" failure this workflow exists to close. |
| 52 | issues: write |
| 53 | steps: |
| 54 | - name: Skip when no review key is configured |
| 55 | if: env.HAS_ANY_KEY != 'true' |
| 56 | run: | |
| 57 | echo "::notice::No Codewhale review ran: model secrets are unavailable or this is a fork PR. Configure a review key for same-repository PRs; review fork PRs separately with a trusted build." |
| 58 | echo "Codewhale review: not run (no eligible review credentials; this is not a clean-review result)." >> "$GITHUB_STEP_SUMMARY" |
| 59 | |
| 60 | - name: Checkout pinned review source |
| 61 | uses: actions/checkout@v7 |
| 62 | with: |
| 63 | ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.event.pull_request.base.sha }} |
| 64 | fetch-depth: 0 |
| 65 | persist-credentials: false |
| 66 | |
| 67 | - name: Make exact PR diff objects available |
| 68 | env: |
| 69 | GH_TOKEN: ${{ github.token }} |
| 70 | PR_NUMBER: ${{ github.event.pull_request.number }} |
| 71 | PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 72 | PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 73 | REVIEW_SOURCE_SHA: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.sha || github.event.pull_request.base.sha }} |
| 74 | run: | |
| 75 | set -euo pipefail |
| 76 | [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "::error::Invalid PR number"; exit 1; } |
| 77 | for SHA in "$PR_HEAD_SHA" "$PR_BASE_SHA" "$REVIEW_SOURCE_SHA"; do |
| 78 | [[ "$SHA" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]] || { echo "::error::Invalid pinned commit"; exit 1; } |
| 79 | done |
| 80 | [ "$(git rev-parse HEAD)" = "$REVIEW_SOURCE_SHA" ] |
| 81 | [ "$(git rev-parse --is-shallow-repository)" = false ] |
| 82 | # Fetch objects via the base repository's PR ref. Do not check out |
| 83 | # the fetched head, initialize submodules, or run PR hooks/filters. |
| 84 | git -c core.hooksPath=/dev/null -c credential.helper= -c 'credential.helper=!gh auth git-credential' \ |
| 85 | fetch --no-tags --no-recurse-submodules origin \ |
| 86 | "+refs/pull/${PR_NUMBER}/head:refs/codewhale-review/head" |
| 87 | [ "$(git rev-parse 'refs/codewhale-review/head^{commit}')" = "$PR_HEAD_SHA" ] || { |
| 88 | echo "::error::PR head changed during checkout; rerun for the current revision." |
| 89 | exit 1 |
| 90 | } |
| 91 | git cat-file -e "${PR_BASE_SHA}^{commit}" |
| 92 | git cat-file -e "${PR_HEAD_SHA}^{commit}" |
| 93 | MERGE_BASE=$(git merge-base --all "$PR_BASE_SHA" "$PR_HEAD_SHA") |
| 94 | [[ "$MERGE_BASE" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]] || { |
| 95 | echo "::error::The pinned PR commits do not have one available merge base." |
| 96 | exit 1 |
| 97 | } |
| 98 | [ "$(git rev-parse HEAD)" = "$REVIEW_SOURCE_SHA" ] |
| 99 | |
| 100 | - name: Mint Codewhale Agent app token |
| 101 | if: env.HAS_ANY_KEY == 'true' && env.HAS_APP_KEY == 'true' && vars.CODEWHALE_APP_ID != '' |
| 102 | id: app-token |
| 103 | uses: actions/create-github-app-token@v3 |
| 104 | with: |
| 105 | app-id: ${{ vars.CODEWHALE_APP_ID }} |
| 106 | private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }} |
| 107 | |
| 108 | - name: Install Rust toolchain |
| 109 | if: env.HAS_ANY_KEY == 'true' |
| 110 | uses: dtolnay/rust-toolchain@stable |
| 111 | |
| 112 | - name: Install native build deps |
| 113 | if: env.HAS_ANY_KEY == 'true' |
| 114 | run: | |
| 115 | for i in 1 2 3; do |
| 116 | sudo apt-get update && break |
| 117 | echo "apt-get update failed (attempt $i); retrying in 15s" |
| 118 | sleep 15 |
| 119 | done |
| 120 | sudo apt-get install -y libdbus-1-dev pkg-config |
| 121 | |
| 122 | - name: Cache cargo build |
| 123 | if: env.HAS_ANY_KEY == 'true' |
| 124 | uses: Swatinem/rust-cache@v2 |
| 125 | |
| 126 | - name: Build codewhale |
| 127 | if: env.HAS_ANY_KEY == 'true' |
| 128 | run: cargo build --release --locked -p codewhale-cli |
| 129 | |
| 130 | - name: Run Codewhale PR review |
| 131 | if: env.HAS_ANY_KEY == 'true' |
| 132 | env: |
| 133 | GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} |
| 134 | # Account machine key, consumed only by the existing account/relay path. |
| 135 | CODEWHALE_API_KEY: ${{ secrets.CODEWHALE_API_KEY }} |
| 136 | # Provider credentials remain separate and are never overwritten. |
| 137 | ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} |
| 138 | # Alibaba Model Studio Token Plan (DeepSeek V4 Pro / Qwen 3.8 on the |
| 139 | # founder's credit); all Model Studio kinds read MODELSTUDIO_API_KEY. |
| 140 | MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} |
| 141 | DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} |
| 142 | OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} |
| 143 | ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} |
| 144 | # One approved release review, using the existing provider secret. |
| 145 | # These ceilings authorize complete ordered passes only for PR #6002; |
| 146 | # repository variables remain explicit operator overrides. |
| 147 | CODEWHALE_REVIEW_PROVIDER: ${{ vars.CODEWHALE_REVIEW_PROVIDER || (github.event.pull_request.number == 6002 && 'deepseek') || '' }} |
| 148 | CODEWHALE_REVIEW_MODEL: ${{ vars.CODEWHALE_REVIEW_MODEL || (github.event.pull_request.number == 6002 && 'deepseek-v4-pro') || '' }} |
| 149 | CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS: ${{ vars.CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS || (github.event.pull_request.number == 6002 && '65536') || '' }} |
| 150 | CODEWHALE_REVIEW_MAX_CHARS: ${{ vars.CODEWHALE_REVIEW_MAX_CHARS || (github.event.pull_request.number == 6002 && '500000') || '200000' }} |
| 151 | CODEWHALE_REVIEW_MAX_PASSES: ${{ vars.CODEWHALE_REVIEW_MAX_PASSES || (github.event.pull_request.number == 6002 && '16') || '1' }} |
| 152 | PR_NUMBER: ${{ github.event.pull_request.number }} |
| 153 | run: | |
| 154 | set -euo pipefail |
| 155 | |
| 156 | # Account mode uses the existing machine precondition and Codewhale |
| 157 | # model relay. A configured account provider is not a vendor key. |
| 158 | PROVIDER="${CODEWHALE_REVIEW_PROVIDER:-}" |
| 159 | MODEL="${CODEWHALE_REVIEW_MODEL:-}" |
| 160 | if [ -n "${CODEWHALE_API_KEY:-}" ]; then |
| 161 | if [ -n "$PROVIDER" ] && [ "$PROVIDER" != codewhale ]; then |
| 162 | echo "::error::With CODEWHALE_API_KEY, set CODEWHALE_REVIEW_PROVIDER=codewhale or leave it unset. Provider keys are never overwritten." |
| 163 | exit 1 |
| 164 | fi |
| 165 | PROVIDER=codewhale |
| 166 | if [[ ! "$MODEL" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[^[:space:]]+$ ]]; then |
| 167 | echo "::error::Account review requires CODEWHALE_REVIEW_MODEL as an exact provider/model id from the account catalog." |
| 168 | exit 1 |
| 169 | fi |
| 170 | ./target/release/codewhale --no-project-config account agent > /dev/null |
| 171 | echo "Review key: Codewhale account relay (provider: codewhale)" |
| 172 | else |
| 173 | if [ -z "$PROVIDER" ]; then |
| 174 | if [ -n "${ZAI_API_KEY:-}" ]; then PROVIDER=zai |
| 175 | elif [ -n "${MODELSTUDIO_API_KEY:-}" ]; then PROVIDER=modelstudio-token-plan |
| 176 | elif [ -n "${DEEPSEEK_API_KEY:-}" ]; then PROVIDER=deepseek |
| 177 | elif [ -n "${OPENROUTER_API_KEY:-}" ]; then PROVIDER=openrouter |
| 178 | elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then PROVIDER=anthropic |
| 179 | fi |
| 180 | fi |
| 181 | if [ -z "$PROVIDER" ]; then |
| 182 | echo "::error::No BYOK review provider is configured." |
| 183 | exit 1 |
| 184 | fi |
| 185 | echo "Review key: BYOK provider secret (provider: ${PROVIDER})" |
| 186 | fi |
| 187 | |
| 188 | MAX_CHARS="${CODEWHALE_REVIEW_MAX_CHARS:-200000}" |
| 189 | if [[ ! "$MAX_CHARS" =~ ^[1-9][0-9]{0,6}$ ]] || [ "$MAX_CHARS" -gt 8388608 ]; then |
| 190 | echo "::error::CODEWHALE_REVIEW_MAX_CHARS must be an integer from 1 to 8388608." |
| 191 | exit 1 |
| 192 | fi |
| 193 | |
| 194 | MAX_PASSES="${CODEWHALE_REVIEW_MAX_PASSES:-1}" |
| 195 | if [[ ! "$MAX_PASSES" =~ ^[1-9][0-9]?$ ]] || [ "$MAX_PASSES" -gt 64 ]; then |
| 196 | echo "::error::CODEWHALE_REVIEW_MAX_PASSES must be an integer from 1 to 64." |
| 197 | exit 1 |
| 198 | fi |
| 199 | echo "Review limits: ${MAX_CHARS} characters per pass, at most ${MAX_PASSES} passes" |
| 200 | |
| 201 | # --- Output budget --------------------------------------------- |
| 202 | # Some models share an output budget between reasoning and content. |
| 203 | # Leave room for the review; unset keeps the CLI's automatic cap. |
| 204 | BUDGET="${CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS:-}" |
| 205 | if [ -n "$BUDGET" ]; then |
| 206 | case "$BUDGET" in |
| 207 | ''|*[!0-9]*) |
| 208 | echo "::error::CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS must be a positive integer (got '${BUDGET}')." |
| 209 | exit 1 ;; |
| 210 | esac |
| 211 | if [ "$BUDGET" -lt 8192 ]; then |
| 212 | echo "::error::CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS=${BUDGET} is below the 8192 floor. Leave room for reasoning and the final review." |
| 213 | exit 1 |
| 214 | fi |
| 215 | export CODEWHALE_MAX_OUTPUT_TOKENS="$BUDGET" |
| 216 | echo "Output budget: CODEWHALE_MAX_OUTPUT_TOKENS=${BUDGET}" |
| 217 | else |
| 218 | echo "Output budget: CLI automatic cap (no override set)" |
| 219 | fi |
| 220 | |
| 221 | # --- Run -------------------------------------------------------- |
| 222 | # Global route/model flags keep the existing CLI resolver on the |
| 223 | # selected credential boundary before the review subcommand starts. |
| 224 | CLI_ARGS=(--no-project-config --provider "$PROVIDER") |
| 225 | if [ -n "$MODEL" ]; then |
| 226 | CLI_ARGS+=(--model "$MODEL") |
| 227 | fi |
| 228 | REVIEW_ARGS=(--pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --max-chars "$MAX_CHARS" --max-passes "$MAX_PASSES" --post) |
| 229 | set +e |
| 230 | OUTPUT=$(./target/release/codewhale "${CLI_ARGS[@]}" review "${REVIEW_ARGS[@]}" 2>&1) |
| 231 | STATUS=$? |
| 232 | set -e |
| 233 | echo "$OUTPUT" |
| 234 | if [ "$STATUS" -eq 0 ]; then |
| 235 | # A reasoning model that spent its whole budget before emitting |
| 236 | # content exits 0 with nothing to say. That is a failure, not a |
| 237 | # clean review — never report it as one. |
| 238 | if [ -z "$(printf '%s' "$OUTPUT" | tr -d '[:space:]')" ]; then |
| 239 | echo "::error::Codewhale review produced empty output with exit 0. If the model is a reasoning model, raise CODEWHALE_REVIEW_MAX_OUTPUT_TOKENS." |
| 240 | exit 1 |
| 241 | fi |
| 242 | MARK="<!-- codewhale-review-nonrun -->" |
| 243 | STALE=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate --jq ".[] | select(.body | startswith(\"${MARK}\")) | .id" 2>/dev/null | head -1 || true) |
| 244 | if [ -n "$STALE" ]; then |
| 245 | gh api -X DELETE "repos/${GITHUB_REPOSITORY}/issues/comments/${STALE}" >/dev/null 2>&1 || true |
| 246 | fi |
| 247 | exit 0 |
| 248 | fi |
| 249 | # The review is advisory: a provider-side outage (balance, auth, |
| 250 | # rate limit, upstream 5xx) must not block the PR. Real review |
| 251 | # failures still fail the job with the original exit status. |
| 252 | if echo "$OUTPUT" | grep -qE 'LLM error: HTTP (401|402|403|408|429|5[0-9][0-9])'; then |
| 253 | REASON=$(echo "$OUTPUT" | grep -oE 'LLM error: HTTP (401|402|403|408|429|5[0-9][0-9])[^"]{0,80}' | head -1) |
| 254 | echo "::warning::Codewhale review could not run (${REASON}). The PR is not blocked — provider funding/config is founder-gated." |
| 255 | echo "Codewhale review: not run (provider unavailable; this is not a clean-review result)." >> "$GITHUB_STEP_SUMMARY" |
| 256 | # Silence is not success: leave one visible, idempotent note on the |
| 257 | # PR so a non-run never passes for a clean review. Only the HTTP |
| 258 | # status line is quoted, never the model output. |
| 259 | MARK="<!-- codewhale-review-nonrun -->" |
| 260 | # Single printf: column-0 continuation lines would terminate the |
| 261 | # YAML block scalar (actionlint syntax-check failure at :249). |
| 262 | # The backticks below are literal Markdown for the PR comment, not |
| 263 | # command substitution; the format string must stay single-quoted. |
| 264 | # shellcheck disable=SC2016 |
| 265 | BODY=$(printf '%s\n\n## Codewhale review did not run\n\n`codewhale review --pr %s` (provider: `%s`) could not reach the model: `%s`.\n%s' "$MARK" "$PR_NUMBER" "$PROVIDER" "$REASON" "This is a provider funding/config problem, not a finding about this PR. The check stays advisory; a maintainer with secret access needs to fund or rotate the review key (see \`.github/workflows/codewhale-review.yml\`). Re-run the workflow after that.") |
| 266 | EXISTING=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate --jq ".[] | select(.body | startswith(\"${MARK}\")) | .id" 2>/dev/null | head -1 || true) |
| 267 | if [ -n "$EXISTING" ]; then |
| 268 | gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" -f body="$BODY" >/dev/null 2>&1 || echo "::warning::could not update the non-run note" |
| 269 | else |
| 270 | gh pr comment "$PR_NUMBER" --body "$BODY" >/dev/null 2>&1 || echo "::warning::could not post the non-run note" |
| 271 | fi |
| 272 | exit 0 |
| 273 | fi |
| 274 | exit "$STATUS" |
| 275 |