返回 CodeWhale
codewhale-review.yml
根目录 / .github / workflows / codewhale-review.yml
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
275 lines YAML