@bridge_gpt/mcp-server 0.2.41 → 0.2.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -191
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +6 -5
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +947 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +148 -1
- package/build/conductor/cli.js +109 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +20 -16
- package/build/credentials-cli.js +3 -2
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +120 -44
- package/build/drive-epic.js +375 -0
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +198 -29
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +259 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +454 -48
- package/build/executor/test-clock.js +3 -2
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +2277 -270
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +225 -47
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +305 -15
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +18 -5
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +100 -10
- package/build/plane/shutdown.js +71 -3
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +149 -6
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +531 -82
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -103,6 +103,172 @@ function yamlSingleQuoted(value) {
|
|
|
103
103
|
function yamlFlowList(values) {
|
|
104
104
|
return `[${values.map(yamlSingleQuoted).join(", ")}]`;
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* The verdict check, as an `actions/github-script` body.
|
|
108
|
+
*
|
|
109
|
+
* A faithful implementation of `docs/claude/claude-review-verdict-contract.md`
|
|
110
|
+
* §1-§4 — the same grammar, candidate eligibility, run-id/recency winner
|
|
111
|
+
* selection, conflict rule, and head binding the Conductor's merge gate applies.
|
|
112
|
+
* A check STRICTER than the gate turns mergeable pull requests red; a check
|
|
113
|
+
* LOOSER than the gate reproduces the silent green this whole state machine
|
|
114
|
+
* exists to remove. Neither side may relax a rule alone: the contract document is
|
|
115
|
+
* the authority both cite.
|
|
116
|
+
*
|
|
117
|
+
* Outputs, read by the retry conditions and the aggregate step:
|
|
118
|
+
*
|
|
119
|
+
* - `status` — `checked` when comments were read and the check completed,
|
|
120
|
+
* `unavailable` when the read itself failed. Only `checked` may trigger another
|
|
121
|
+
* paid attempt; another review cannot fix a check that could not read its input.
|
|
122
|
+
* - `valid` — `true` only for a verdict bound to the exact expected head.
|
|
123
|
+
* - `reason` — a stable code from the contract's fail-closed vocabulary.
|
|
124
|
+
*
|
|
125
|
+
* Read-only throughout: it never posts, edits, or deletes a comment. Diagnostics
|
|
126
|
+
* carry reason codes and commit SHAs only — never a comment body, a user login,
|
|
127
|
+
* or a token, because an Actions log is world-readable on a public repository.
|
|
128
|
+
*/
|
|
129
|
+
const VERDICT_CHECK_SCRIPT = [
|
|
130
|
+
" // Contract: docs/claude/claude-review-verdict-contract.md §1-§4.",
|
|
131
|
+
" const VERDICT = /claude-review-verdict:\\s*(approved|changes_requested)/i;",
|
|
132
|
+
" const RUN_ID = /run[-_]id:\\s*(\\d+)/i;",
|
|
133
|
+
" const REVIEWED_SHA = /Reviewed-SHA:\\s*([0-9A-Za-z._-]+)/i;",
|
|
134
|
+
" const FULL_SHA = /^[0-9a-f]{40}$/;",
|
|
135
|
+
" const APPROVED_WORD = /\\bapproved\\b/i;",
|
|
136
|
+
" const CHANGES_WORD = /\\bchanges.requested\\b/i;",
|
|
137
|
+
" const expected = String(process.env.EXPECTED_HEAD || '').trim().toLowerCase();",
|
|
138
|
+
"",
|
|
139
|
+
" // Ambiguity (both verdict words) or absence (neither) yields",
|
|
140
|
+
" // 'unknown', never 'approved'.",
|
|
141
|
+
" const verdictOf = (body) => {",
|
|
142
|
+
" const token = VERDICT.exec(body);",
|
|
143
|
+
" if (token) return token[1].toLowerCase();",
|
|
144
|
+
" const a = APPROVED_WORD.test(body);",
|
|
145
|
+
" const c = CHANGES_WORD.test(body);",
|
|
146
|
+
" if (a && !c) return 'approved';",
|
|
147
|
+
" if (c && !a) return 'changes_requested';",
|
|
148
|
+
" return 'unknown';",
|
|
149
|
+
" };",
|
|
150
|
+
" const declaredSha = (body) => {",
|
|
151
|
+
" const m = REVIEWED_SHA.exec(body);",
|
|
152
|
+
" return m ? m[1].trim().toLowerCase() : null;",
|
|
153
|
+
" };",
|
|
154
|
+
"",
|
|
155
|
+
" // §2 eligibility: the structured token, or the loose marker.",
|
|
156
|
+
" // Malformed entries are skipped rather than throwing — one odd",
|
|
157
|
+
" // comment must not fail the job for a reason unrelated to review.",
|
|
158
|
+
" const build = (comments) => comments.reduce((acc, c, index) => {",
|
|
159
|
+
" if (!c || typeof c !== 'object') return acc;",
|
|
160
|
+
" const body = typeof c.body === 'string' ? c.body : '';",
|
|
161
|
+
" const hasToken = VERDICT.test(body);",
|
|
162
|
+
" if (!hasToken && body.indexOf('claude-review') === -1) return acc;",
|
|
163
|
+
" const run = RUN_ID.exec(body);",
|
|
164
|
+
" const raw = c.updated_at || c.created_at;",
|
|
165
|
+
" const ts = typeof raw === 'string' ? Date.parse(raw) : NaN;",
|
|
166
|
+
" acc.push({",
|
|
167
|
+
" runId: run ? parseInt(run[1], 10) : 0,",
|
|
168
|
+
" index,",
|
|
169
|
+
" body,",
|
|
170
|
+
" hasToken,",
|
|
171
|
+
" ts: Number.isNaN(ts) ? null : ts,",
|
|
172
|
+
" });",
|
|
173
|
+
" return acc;",
|
|
174
|
+
" }, []);",
|
|
175
|
+
"",
|
|
176
|
+
" // §3 legacy rank: (has a usable timestamp, that instant, carries the",
|
|
177
|
+
" // token). The array index is deliberately EXCLUDED so a genuine",
|
|
178
|
+
" // two-verdict contradiction stays detectable instead of being",
|
|
179
|
+
" // silently resolved as 'whichever came last'.",
|
|
180
|
+
" const rankOf = (c) => [c.ts !== null ? 1 : 0, c.ts === null ? 0 : c.ts, c.hasToken ? 1 : 0];",
|
|
181
|
+
" const cmpRank = (a, b) => {",
|
|
182
|
+
" const x = rankOf(a);",
|
|
183
|
+
" const y = rankOf(b);",
|
|
184
|
+
" for (let i = 0; i < x.length; i++) {",
|
|
185
|
+
" if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;",
|
|
186
|
+
" }",
|
|
187
|
+
" return 0;",
|
|
188
|
+
" };",
|
|
189
|
+
"",
|
|
190
|
+
" // §3 conflict: equal-ranked candidates that DISAGREE and share one",
|
|
191
|
+
" // valid Reviewed-SHA. Duplicate upserts of the same verdict are not",
|
|
192
|
+
" // a conflict, and candidates without shared provenance never proved",
|
|
193
|
+
" // they examined the same code.",
|
|
194
|
+
" const conflicts = (tied) => {",
|
|
195
|
+
" if (new Set(tied.map((c) => verdictOf(c.body))).size < 2) return false;",
|
|
196
|
+
" const shas = new Set(",
|
|
197
|
+
" tied.map((c) => declaredSha(c.body)).filter((s) => s && FULL_SHA.test(s)),",
|
|
198
|
+
" );",
|
|
199
|
+
" if (shas.size !== 1) return false;",
|
|
200
|
+
" const bound = tied.filter((c) => shas.has(declaredSha(c.body)));",
|
|
201
|
+
" return new Set(bound.map((c) => verdictOf(c.body))).size >= 2;",
|
|
202
|
+
" };",
|
|
203
|
+
"",
|
|
204
|
+
" const resolve = (comments) => {",
|
|
205
|
+
" const candidates = build(comments);",
|
|
206
|
+
" if (candidates.length === 0) return { valid: false, reason: 'review_verdict_absent' };",
|
|
207
|
+
" let tied;",
|
|
208
|
+
" if (candidates.some((c) => c.runId > 0)) {",
|
|
209
|
+
" const top = Math.max(...candidates.map((c) => c.runId));",
|
|
210
|
+
" tied = candidates.filter((c) => c.runId === top);",
|
|
211
|
+
" } else {",
|
|
212
|
+
" const best = candidates.reduce((a, b) => (cmpRank(a, b) >= 0 ? a : b));",
|
|
213
|
+
" tied = candidates.filter((c) => cmpRank(c, best) === 0);",
|
|
214
|
+
" }",
|
|
215
|
+
" if (conflicts(tied)) return { valid: false, reason: 'review_verdict_conflict' };",
|
|
216
|
+
" const winner = tied.reduce((a, b) => (a.index >= b.index ? a : b));",
|
|
217
|
+
" const declared = declaredSha(winner.body);",
|
|
218
|
+
" if (declared === null) return { valid: false, reason: 'review_verdict_no_head_proof' };",
|
|
219
|
+
" if (declared !== expected) {",
|
|
220
|
+
" return {",
|
|
221
|
+
" valid: false,",
|
|
222
|
+
" reason: FULL_SHA.test(declared) ? 'review_verdict_stale_head' : 'review_verdict_malformed_sha',",
|
|
223
|
+
" };",
|
|
224
|
+
" }",
|
|
225
|
+
" const verdict = verdictOf(winner.body);",
|
|
226
|
+
" if (verdict !== 'approved' && verdict !== 'changes_requested') {",
|
|
227
|
+
" return { valid: false, reason: 'review_verdict_unknown_token' };",
|
|
228
|
+
" }",
|
|
229
|
+
" return { valid: true, reason: 'review_verdict_resolved', verdict };",
|
|
230
|
+
" };",
|
|
231
|
+
"",
|
|
232
|
+
" if (!FULL_SHA.test(expected)) {",
|
|
233
|
+
" core.setOutput('status', 'unavailable');",
|
|
234
|
+
" core.setOutput('valid', 'false');",
|
|
235
|
+
" core.setOutput('reason', 'checker_invalid_expected_head');",
|
|
236
|
+
" return;",
|
|
237
|
+
" }",
|
|
238
|
+
"",
|
|
239
|
+
" // Bounded, read-only propagation loop: a verdict posted seconds ago",
|
|
240
|
+
" // may not be visible to the comments API yet, and calling that",
|
|
241
|
+
" // 'absent' would spend another paid review for nothing.",
|
|
242
|
+
" let outcome = { valid: false, reason: 'checker_not_run' };",
|
|
243
|
+
" let status = 'unavailable';",
|
|
244
|
+
" for (let attempt = 1; attempt <= 4; attempt++) {",
|
|
245
|
+
" let comments;",
|
|
246
|
+
" try {",
|
|
247
|
+
" comments = await github.paginate(github.rest.issues.listComments, {",
|
|
248
|
+
" owner: context.repo.owner,",
|
|
249
|
+
" repo: context.repo.repo,",
|
|
250
|
+
" issue_number: Number(process.env.PR_NUMBER),",
|
|
251
|
+
" per_page: 100,",
|
|
252
|
+
" });",
|
|
253
|
+
" } catch (err) {",
|
|
254
|
+
" status = 'unavailable';",
|
|
255
|
+
" outcome = { valid: false, reason: 'comment_fetch_failed' };",
|
|
256
|
+
" await new Promise((r) => setTimeout(r, 10000));",
|
|
257
|
+
" continue;",
|
|
258
|
+
" }",
|
|
259
|
+
" status = 'checked';",
|
|
260
|
+
" outcome = resolve(comments);",
|
|
261
|
+
" const retryable =",
|
|
262
|
+
" outcome.reason === 'review_verdict_absent' ||",
|
|
263
|
+
" outcome.reason === 'review_verdict_stale_head';",
|
|
264
|
+
" if (outcome.valid || !retryable || attempt === 4) break;",
|
|
265
|
+
" await new Promise((r) => setTimeout(r, 10000));",
|
|
266
|
+
" }",
|
|
267
|
+
" core.setOutput('status', status);",
|
|
268
|
+
" core.setOutput('valid', outcome.valid ? 'true' : 'false');",
|
|
269
|
+
" core.setOutput('reason', outcome.reason);",
|
|
270
|
+
" core.info('Verdict check: valid=' + outcome.valid + ' reason=' + outcome.reason);",
|
|
271
|
+
].join("\n");
|
|
106
272
|
/**
|
|
107
273
|
* Render the workflow YAML deterministically from validated parameters.
|
|
108
274
|
*
|
|
@@ -111,6 +277,22 @@ function yamlFlowList(values) {
|
|
|
111
277
|
* `Reviewed-SHA`/`run-id` contract the Conductor's merge gate consumes — with
|
|
112
278
|
* every org-specific literal replaced by a parameter.
|
|
113
279
|
*
|
|
280
|
+
* BAPI-851 widened "structurally equivalent" to include the whole PRODUCER-SIDE
|
|
281
|
+
* VERDICT CONTRACT, not just the prompt: three bounded, timeout-capped review
|
|
282
|
+
* attempts; a fresh head guard before each one; a fail-closed verdict check after
|
|
283
|
+
* each one; a terminal marker whose conclusion is the aggregate check result; and
|
|
284
|
+
* a final step that fails the job when no head-bound verdict was published. A
|
|
285
|
+
* consumer repo installing this workflow otherwise inherits the exact defect
|
|
286
|
+
* BAPI-851 removed here — a green check with no verdict, which reads as approval
|
|
287
|
+
* to anyone who does not know better.
|
|
288
|
+
*
|
|
289
|
+
* The rendered workflow does the verdict check in `actions/github-script`, while
|
|
290
|
+
* the Bridge repo's own workflow shells out to `scripts/check_claude_review_verdict.py`.
|
|
291
|
+
* They are two implementations of ONE contract —
|
|
292
|
+
* `docs/claude/claude-review-verdict-contract.md` §1-§4 — because a generated
|
|
293
|
+
* workflow cannot depend on a Python file living in the Bridge repository. The
|
|
294
|
+
* contract document, not either implementation, is the authority.
|
|
295
|
+
*
|
|
114
296
|
* @throws ClaudeReviewWorkflowValidationError before producing any output.
|
|
115
297
|
*/
|
|
116
298
|
export function renderClaudeReviewWorkflow(params) {
|
|
@@ -120,8 +302,141 @@ export function renderClaudeReviewWorkflow(params) {
|
|
|
120
302
|
// `${'$'}{{ ... }}` is avoided by building the expression marker from a constant,
|
|
121
303
|
// so nothing in this template can be mistaken for a TS template substitution.
|
|
122
304
|
const gh = (expr) => "${{ " + expr + " }}";
|
|
123
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Preflight: will the review action run at all?
|
|
307
|
+
*
|
|
308
|
+
* `anthropics/claude-code-action` refuses to run when the workflow file differs
|
|
309
|
+
* from the version on the repository's DEFAULT branch — its own supply-chain
|
|
310
|
+
* guard, so a pull request cannot rewrite the reviewer that judges it. It
|
|
311
|
+
* reports that as a warning and exits SUCCESS, so without this preflight the
|
|
312
|
+
* attempts would each publish nothing and the run would blame
|
|
313
|
+
* `retries_exhausted_without_verdict`, naming a cause that did not occur.
|
|
314
|
+
*/
|
|
315
|
+
const preflight = () => ` - name: Preflight the review action
|
|
316
|
+
id: preflight
|
|
317
|
+
env:
|
|
318
|
+
DEFAULT_BRANCH: ${gh("github.event.repository.default_branch")}
|
|
319
|
+
uses: actions/github-script@v9
|
|
320
|
+
with:
|
|
321
|
+
script: |
|
|
322
|
+
const fs = require('fs');
|
|
323
|
+
const crypto = require('crypto');
|
|
324
|
+
// Reproduce git's blob hash so it can be compared with the contents
|
|
325
|
+
// API's own \`sha\`, which avoids downloading and decoding the file.
|
|
326
|
+
const raw = fs.readFileSync('.github/workflows/claude-review.yml');
|
|
327
|
+
const header = Buffer.from('blob ' + raw.length + '\\0', 'utf8');
|
|
328
|
+
const localSha = crypto.createHash('sha1')
|
|
329
|
+
.update(Buffer.concat([header, raw]))
|
|
330
|
+
.digest('hex');
|
|
331
|
+
let remoteSha = '';
|
|
332
|
+
try {
|
|
333
|
+
const res = await github.rest.repos.getContent({
|
|
334
|
+
owner: context.repo.owner,
|
|
335
|
+
repo: context.repo.repo,
|
|
336
|
+
path: '.github/workflows/claude-review.yml',
|
|
337
|
+
ref: String(process.env.DEFAULT_BRANCH || ''),
|
|
338
|
+
});
|
|
339
|
+
remoteSha = String(res.data.sha || '');
|
|
340
|
+
} catch (err) {
|
|
341
|
+
remoteSha = '';
|
|
342
|
+
}
|
|
343
|
+
if (!remoteSha) {
|
|
344
|
+
// Cannot prove a mismatch. Fail OPEN toward attempting the review: a
|
|
345
|
+
// suppressed review that would have worked is worse than a wasted
|
|
346
|
+
// attempt, and enforcement still catches a missing verdict.
|
|
347
|
+
core.setOutput('action_runnable', 'true');
|
|
348
|
+
core.setOutput('status', 'unverified');
|
|
349
|
+
core.info('Could not read the default branch copy of this workflow; proceeding.');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (localSha === remoteSha) {
|
|
353
|
+
core.setOutput('action_runnable', 'true');
|
|
354
|
+
core.setOutput('status', 'ok');
|
|
355
|
+
} else {
|
|
356
|
+
core.setOutput('action_runnable', 'false');
|
|
357
|
+
core.setOutput('status', 'workflow_modified');
|
|
358
|
+
core.warning('This pull request modifies the review workflow, so the review action will refuse to run against it. Suppressing all attempts; review this pull request by hand.');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
`;
|
|
362
|
+
/** One paid review attempt: guard the head, review, then check for a verdict. */
|
|
363
|
+
const attempt = (n) => {
|
|
364
|
+
const prev = n - 1;
|
|
365
|
+
const guardIf = n === 1
|
|
366
|
+
? " if: steps.preflight.outputs.action_runnable == 'true'\n"
|
|
367
|
+
: ` if: steps.check${prev}.outputs.status == 'checked' && steps.check${prev}.outputs.valid != 'true'\n`;
|
|
368
|
+
return ` - name: Guard head (attempt ${n})
|
|
369
|
+
id: guard${n}
|
|
370
|
+
${guardIf} uses: actions/github-script@v9
|
|
371
|
+
with:
|
|
372
|
+
script: |
|
|
373
|
+
// Never spend a paid review on a head the PR has already moved past.
|
|
374
|
+
// A failed lookup is NOT read as "unchanged": an unprovable head is an
|
|
375
|
+
// infrastructure failure, not permission to review.
|
|
376
|
+
const expected = String(process.env.EXPECTED_HEAD || '').toLowerCase();
|
|
377
|
+
let actual = '';
|
|
378
|
+
try {
|
|
379
|
+
const pr = await github.rest.pulls.get({
|
|
380
|
+
owner: context.repo.owner,
|
|
381
|
+
repo: context.repo.repo,
|
|
382
|
+
pull_number: Number(process.env.PR_NUMBER),
|
|
383
|
+
});
|
|
384
|
+
actual = String(pr.data.head.sha || '').toLowerCase();
|
|
385
|
+
} catch (err) {
|
|
386
|
+
core.setOutput('head_ok', 'false');
|
|
387
|
+
core.setOutput('status', 'lookup_failed');
|
|
388
|
+
core.warning('Could not resolve the pull request head; refusing to start a review attempt.');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (!/^[0-9a-f]{40}$/.test(actual)) {
|
|
392
|
+
core.setOutput('head_ok', 'false');
|
|
393
|
+
core.setOutput('status', 'lookup_failed');
|
|
394
|
+
core.warning('Pull request head was not a usable commit SHA.');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (actual !== expected) {
|
|
398
|
+
core.setOutput('head_ok', 'false');
|
|
399
|
+
core.setOutput('status', 'drift');
|
|
400
|
+
core.warning('PR head moved from ' + expected + ' to ' + actual + '; suppressing remaining attempts.');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
core.setOutput('head_ok', 'true');
|
|
404
|
+
core.setOutput('status', 'ok');
|
|
405
|
+
core.info('PR head confirmed at ' + expected + '.');
|
|
406
|
+
|
|
407
|
+
- name: Claude Code Review (attempt ${n})
|
|
408
|
+
id: review${n}
|
|
409
|
+
if: steps.guard${n}.outputs.head_ok == 'true'
|
|
410
|
+
continue-on-error: true
|
|
411
|
+
timeout-minutes: 10
|
|
412
|
+
uses: anthropics/claude-code-action@v1
|
|
413
|
+
with:
|
|
414
|
+
claude_code_oauth_token: ${gh(`secrets.${secretName}`)}
|
|
415
|
+
allowed_bots: ${yamlSingleQuoted(allowedBot)}
|
|
416
|
+
use_sticky_comment: true
|
|
417
|
+
track_progress: true
|
|
418
|
+
prompt: ${gh("env.REVIEW_PROMPT")}
|
|
419
|
+
claude_args: ${gh("env.REVIEW_CLAUDE_ARGS")}
|
|
420
|
+
|
|
421
|
+
# An action failure or a ten-minute timeout does NOT skip the check: the
|
|
422
|
+
# agent may have published a valid verdict before the step returned
|
|
423
|
+
# non-success. Only the checker decides whether this attempt counted.
|
|
424
|
+
- name: Check verdict (attempt ${n})
|
|
425
|
+
id: check${n}
|
|
426
|
+
if: always() && steps.guard${n}.outputs.head_ok == 'true'
|
|
427
|
+
uses: actions/github-script@v9
|
|
428
|
+
with:
|
|
429
|
+
script: |
|
|
430
|
+
${VERDICT_CHECK_SCRIPT}
|
|
431
|
+
`;
|
|
432
|
+
};
|
|
433
|
+
return `# Generated by \`install-bridge conductor\` (Bridge API, BAPI-679/BAPI-851).
|
|
124
434
|
# Safe to edit: this file is never overwritten without explicit consent.
|
|
435
|
+
#
|
|
436
|
+
# The verdict grammar, winner selection, head binding, and every terminal shape
|
|
437
|
+
# this workflow can produce are specified in ONE place — Bridge API's
|
|
438
|
+
# docs/claude/claude-review-verdict-contract.md. The checker below and the
|
|
439
|
+
# Conductor's merge gate both implement that document; neither defines its own.
|
|
125
440
|
name: Claude Code Review
|
|
126
441
|
|
|
127
442
|
on:
|
|
@@ -133,6 +448,10 @@ on:
|
|
|
133
448
|
# Reviewed-SHA provenance the review emits: the Conductor rejects a verdict whose
|
|
134
449
|
# Reviewed-SHA does not match the current head, so cancelling a superseded review
|
|
135
450
|
# only avoids wasted minutes.
|
|
451
|
+
#
|
|
452
|
+
# The bounded retry below runs as sequential steps INSIDE this job. It never
|
|
453
|
+
# dispatches or re-runs the workflow, so a retry never joins this group and can
|
|
454
|
+
# never cancel a legitimately in-flight review of a newer head.
|
|
136
455
|
concurrency:
|
|
137
456
|
group: claude-review-${gh("github.event.pull_request.number")}
|
|
138
457
|
cancel-in-progress: true
|
|
@@ -147,58 +466,204 @@ jobs:
|
|
|
147
466
|
pull-requests: write
|
|
148
467
|
issues: write
|
|
149
468
|
id-token: write
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
469
|
+
env:
|
|
470
|
+
# The IMMUTABLE head this run was triggered for. Every attempt reviews it and
|
|
471
|
+
# every check demands a verdict bound to it, so no later step can quietly
|
|
472
|
+
# drift onto a different head.
|
|
473
|
+
EXPECTED_HEAD: ${gh("github.event.pull_request.head.sha")}
|
|
474
|
+
PR_NUMBER: ${gh("github.event.pull_request.number")}
|
|
475
|
+
# Held once and passed to all three attempts, so a retry cannot review under
|
|
476
|
+
# different instructions than the first try.
|
|
477
|
+
REVIEW_PROMPT: |
|
|
478
|
+
REPO: ${gh("github.repository")}
|
|
479
|
+
PR NUMBER: ${gh("github.event.pull_request.number")}
|
|
154
480
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
prompt: |
|
|
164
|
-
REPO: ${gh("github.repository")}
|
|
165
|
-
PR NUMBER: ${gh("github.event.pull_request.number")}
|
|
481
|
+
Review this pull request with a focus on:
|
|
482
|
+
- Bugs, logic errors, and edge cases
|
|
483
|
+
- Security issues (injection, auth bypasses, sensitive data exposure)
|
|
484
|
+
- Project conventions and patterns (see CLAUDE.md)
|
|
485
|
+
- Data-access correctness (parameterized queries, migration safety)
|
|
486
|
+
- Test coverage gaps for changed code
|
|
487
|
+
|
|
488
|
+
Be specific: cite file paths and line numbers.
|
|
166
489
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
- Security issues (injection, auth bypasses, sensitive data exposure)
|
|
170
|
-
- Project conventions and patterns (see CLAUDE.md)
|
|
171
|
-
- Data-access correctness (parameterized queries, migration safety)
|
|
172
|
-
- Test coverage gaps for changed code
|
|
490
|
+
Call out significant issues; ignore minor nits with no meaningful impact.
|
|
491
|
+
If there are no significant issues, just say "Looks good to me!"
|
|
173
492
|
|
|
174
|
-
|
|
493
|
+
Use \`gh pr comment\` for top-level feedback.
|
|
494
|
+
Use \`mcp__github_inline_comment__create_inline_comment\` (with \`confirmed: true\`)
|
|
495
|
+
to highlight specific code issues.
|
|
496
|
+
Only post GitHub comments - don't submit review text as messages.
|
|
175
497
|
|
|
176
|
-
|
|
177
|
-
|
|
498
|
+
After your feedback, end your top-level \`gh pr comment\` with EXACTLY ONE
|
|
499
|
+
machine-readable verdict line, on its own line, verbatim — this single line is
|
|
500
|
+
the source of truth consumed by automation (the Conductor merge gate):
|
|
501
|
+
- \`claude-review-verdict: approved\` — when the PR is safe to merge
|
|
502
|
+
- \`claude-review-verdict: changes_requested\` — when significant issues remain
|
|
503
|
+
Emit exactly one such verdict line, and do not use the words \`approved\` or
|
|
504
|
+
\`changes_requested\` as a verdict anywhere else in the comment.
|
|
178
505
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
506
|
+
Immediately BELOW the verdict line, on its own line, emit this exact
|
|
507
|
+
head-SHA provenance line verbatim — it binds the verdict to the exact commit
|
|
508
|
+
you reviewed so the Conductor can reject a stale verdict:
|
|
509
|
+
\`Reviewed-SHA: ${gh("github.event.pull_request.head.sha")}\`
|
|
510
|
+
|
|
511
|
+
Also include the GitHub Actions run id on its own line so overlapping
|
|
512
|
+
reviews resolve deterministically by run id, verbatim:
|
|
513
|
+
\`run-id: ${gh("github.run_id")}\`
|
|
514
|
+
REVIEW_CLAUDE_ARGS: |
|
|
515
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Read,Glob,Grep"
|
|
516
|
+
steps:
|
|
517
|
+
- uses: actions/checkout@v7
|
|
518
|
+
with:
|
|
519
|
+
fetch-depth: 1
|
|
183
520
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
# Three bounded attempts. The review action can report success while
|
|
523
|
+
# having abandoned its own progress checklist and published no verdict;
|
|
524
|
+
# every observed occurrence recovered on a re-run at the identical head.
|
|
525
|
+
# So: retry up to three times in-job, then fail loudly. Each attempt bills
|
|
526
|
+
# a full review, which is what the cap of three is for — a deterministic
|
|
527
|
+
# failure (a dead OAuth token) must not bill indefinitely.
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
${preflight()}${attempt(1)}
|
|
530
|
+
${attempt(2)}
|
|
531
|
+
${attempt(3)}
|
|
532
|
+
# ONE boolean drives both the terminal marker and the job conclusion, so the
|
|
533
|
+
# two can never tell contradictory stories. Aggregate success requires a
|
|
534
|
+
# CHECK to have confirmed a head-bound verdict — three self-reported
|
|
535
|
+
# successful attempts that published nothing aggregate to failure.
|
|
536
|
+
- name: Resolve aggregate review result
|
|
537
|
+
id: aggregate
|
|
538
|
+
if: always()
|
|
539
|
+
# Step outputs reach the script through \`env\`, never through a bare
|
|
540
|
+
# expression interpolated into JS: a skipped step's output is the EMPTY
|
|
541
|
+
# STRING, which would splice into a syntax error rather than a falsy value.
|
|
542
|
+
env:
|
|
543
|
+
PREFLIGHT_STATUS: ${gh("steps.preflight.outputs.status")}
|
|
544
|
+
CHECK1_VALID: ${gh("steps.check1.outputs.valid")}
|
|
545
|
+
CHECK2_VALID: ${gh("steps.check2.outputs.valid")}
|
|
546
|
+
CHECK3_VALID: ${gh("steps.check3.outputs.valid")}
|
|
547
|
+
CHECK1_STATUS: ${gh("steps.check1.outputs.status")}
|
|
548
|
+
CHECK2_STATUS: ${gh("steps.check2.outputs.status")}
|
|
549
|
+
CHECK3_STATUS: ${gh("steps.check3.outputs.status")}
|
|
550
|
+
GUARD1_STATUS: ${gh("steps.guard1.outputs.status")}
|
|
551
|
+
GUARD2_STATUS: ${gh("steps.guard2.outputs.status")}
|
|
552
|
+
GUARD3_STATUS: ${gh("steps.guard3.outputs.status")}
|
|
553
|
+
uses: actions/github-script@v9
|
|
554
|
+
with:
|
|
555
|
+
script: |
|
|
556
|
+
const env = process.env;
|
|
557
|
+
const checks = [
|
|
558
|
+
{ valid: env.CHECK1_VALID, status: env.CHECK1_STATUS },
|
|
559
|
+
{ valid: env.CHECK2_VALID, status: env.CHECK2_STATUS },
|
|
560
|
+
{ valid: env.CHECK3_VALID, status: env.CHECK3_STATUS },
|
|
561
|
+
].map((c) => ({ valid: String(c.valid || ''), status: String(c.status || '') }));
|
|
562
|
+
const guards = [env.GUARD1_STATUS, env.GUARD2_STATUS, env.GUARD3_STATUS]
|
|
563
|
+
.map((s) => String(s || ''));
|
|
564
|
+
const success = checks.some((c) => c.valid === 'true');
|
|
565
|
+
let reason;
|
|
566
|
+
if (success) {
|
|
567
|
+
reason = 'verdict_published';
|
|
568
|
+
} else if (env.PREFLIGHT_STATUS === 'workflow_modified') {
|
|
569
|
+
reason = 'review_action_blocked_by_workflow_modification';
|
|
570
|
+
} else if (guards.includes('drift')) {
|
|
571
|
+
reason = 'head_drift';
|
|
572
|
+
} else if (guards.includes('lookup_failed')) {
|
|
573
|
+
reason = 'head_lookup_failed';
|
|
574
|
+
} else if (checks.some((c) => c.status === 'unavailable')) {
|
|
575
|
+
reason = 'verdict_check_unavailable';
|
|
576
|
+
} else {
|
|
577
|
+
reason = 'retries_exhausted_without_verdict';
|
|
578
|
+
}
|
|
579
|
+
core.setOutput('success', success ? 'true' : 'false');
|
|
580
|
+
core.setOutput('conclusion', success ? 'success' : 'failure');
|
|
581
|
+
core.setOutput('reason', reason);
|
|
582
|
+
core.info('Aggregate review result: success=' + success + ' reason=' + reason);
|
|
191
583
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
584
|
+
# A durable, machine-readable TERMINAL marker recording the workflow run id,
|
|
585
|
+
# the exact reviewed head SHA, and the run's terminal conclusion — WITHOUT
|
|
586
|
+
# ever fabricating a review verdict. It lives in its OWN dedicated comment,
|
|
587
|
+
# whose grammar shares none of the substrings the sticky-verdict parser keys
|
|
588
|
+
# on, and is upserted per PR. \`terminal-conclusion: success\` is published on
|
|
589
|
+
# precisely the path that also lets this job finish green, and still never
|
|
590
|
+
# means "approved" — only the sticky verdict answers that.
|
|
591
|
+
- name: Emit terminal review marker
|
|
592
|
+
id: marker
|
|
593
|
+
if: always()
|
|
594
|
+
continue-on-error: true
|
|
595
|
+
env:
|
|
596
|
+
AGGREGATE_CONCLUSION: ${gh("steps.aggregate.outputs.conclusion")}
|
|
597
|
+
uses: actions/github-script@v9
|
|
598
|
+
with:
|
|
599
|
+
script: |
|
|
600
|
+
const anchor = '<!-- bapi-review-terminal -->';
|
|
601
|
+
const runId = String(context.runId);
|
|
602
|
+
const reviewedHead = context.payload.pull_request
|
|
603
|
+
? String(context.payload.pull_request.head.sha)
|
|
604
|
+
: '';
|
|
605
|
+
const conclusion = String(process.env.AGGREGATE_CONCLUSION || 'unknown');
|
|
606
|
+
const prNumber = context.payload.pull_request
|
|
607
|
+
? context.payload.pull_request.number
|
|
608
|
+
: (context.issue && context.issue.number);
|
|
609
|
+
if (!prNumber) {
|
|
610
|
+
core.warning('No PR number in context; skipping terminal marker.');
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const body = [
|
|
614
|
+
anchor,
|
|
615
|
+
'bapi-review-terminal: v1',
|
|
616
|
+
'terminal-workflow-run: ' + runId,
|
|
617
|
+
'terminal-reviewed-head: ' + reviewedHead,
|
|
618
|
+
'terminal-conclusion: ' + conclusion,
|
|
619
|
+
].join('\\n');
|
|
620
|
+
const existing = await github.paginate(github.rest.issues.listComments, {
|
|
621
|
+
owner: context.repo.owner,
|
|
622
|
+
repo: context.repo.repo,
|
|
623
|
+
issue_number: prNumber,
|
|
624
|
+
per_page: 100,
|
|
625
|
+
});
|
|
626
|
+
const markerComment = existing.find((c) => c.body && c.body.includes(anchor));
|
|
627
|
+
if (markerComment) {
|
|
628
|
+
await github.rest.issues.updateComment({
|
|
629
|
+
owner: context.repo.owner,
|
|
630
|
+
repo: context.repo.repo,
|
|
631
|
+
comment_id: markerComment.id,
|
|
632
|
+
body,
|
|
633
|
+
});
|
|
634
|
+
} else {
|
|
635
|
+
await github.rest.issues.createComment({
|
|
636
|
+
owner: context.repo.owner,
|
|
637
|
+
repo: context.repo.repo,
|
|
638
|
+
issue_number: prNumber,
|
|
639
|
+
body,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
core.info('Emitted terminal review marker: conclusion=' + conclusion);
|
|
196
643
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
644
|
+
# The loud failure. A review that published nothing used to produce a green
|
|
645
|
+
# check, which reads as approval to anyone who does not know better. Now the
|
|
646
|
+
# check goes red — but only after the retries are exhausted, so a single
|
|
647
|
+
# early quit never surfaces as red to a human. Marker-upsert failure also
|
|
648
|
+
# fails the job: a green check with no durable terminal evidence is
|
|
649
|
+
# indistinguishable from a review that never ran.
|
|
650
|
+
- name: Enforce review verdict
|
|
651
|
+
if: always()
|
|
652
|
+
env:
|
|
653
|
+
AGGREGATE_SUCCESS: ${gh("steps.aggregate.outputs.success")}
|
|
654
|
+
AGGREGATE_REASON: ${gh("steps.aggregate.outputs.reason")}
|
|
655
|
+
MARKER_OUTCOME: ${gh("steps.marker.outcome")}
|
|
656
|
+
run: |
|
|
657
|
+
set -uo pipefail
|
|
658
|
+
if [ "\${MARKER_OUTCOME}" != "success" ]; then
|
|
659
|
+
echo "::error::The durable terminal review marker could not be published (\${MARKER_OUTCOME})."
|
|
660
|
+
exit 1
|
|
661
|
+
fi
|
|
662
|
+
if [ "\${AGGREGATE_SUCCESS}" != "true" ]; then
|
|
663
|
+
echo "::error::claude-review published no verdict bound to \${EXPECTED_HEAD} after its bounded attempts (\${AGGREGATE_REASON})."
|
|
664
|
+
exit 1
|
|
665
|
+
fi
|
|
666
|
+
echo "claude-review published a verdict bound to \${EXPECTED_HEAD}."
|
|
202
667
|
`;
|
|
203
668
|
}
|
|
204
669
|
function joinCwd(cwd, rel) {
|