crewly 1.20.376 → 1.20.379
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/config/hooks/destructive-git-guard/guard.mjs +168 -9
- package/config/hooks/destructive-git-guard/local-gates.json +13 -0
- package/config/skills/agent/core/record-local-gate/SKILL.md +46 -0
- package/config/skills/agent/core/record-local-gate/execute.sh +87 -0
- package/config/skills/agent/core/record-local-gate/execute.test.sh +64 -0
- package/config/skills/agent/core/record-tl-verification/SKILL.md +39 -0
- package/config/skills/agent/core/record-tl-verification/execute.sh +71 -0
- package/config/skills/agent/core/record-tl-verification/execute.test.sh +56 -0
- package/dist/backend/backend/src/constants.d.ts +64 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +64 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/experiments/experiments.routes.d.ts +1 -1
- package/dist/backend/backend/src/controllers/experiments/experiments.routes.js +1 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.d.ts +17 -0
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.js +49 -0
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.routes.d.ts +2 -0
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.routes.js +5 -1
- package/dist/backend/backend/src/controllers/owner-receipt/owner-receipt.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +7 -1
- package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/trace/trace.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/trace/trace.controller.js +60 -7
- package/dist/backend/backend/src/controllers/trace/trace.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +23 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/experiments/experiment.service.d.ts +24 -1
- package/dist/backend/backend/src/services/experiments/experiment.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/experiments/experiment.service.js +49 -0
- package/dist/backend/backend/src/services/experiments/experiment.service.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts +22 -0
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js +64 -3
- package/dist/backend/backend/src/services/project-tickets/project-ticket-workflow.service.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +30 -2
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +63 -7
- package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js.map +1 -1
- package/dist/backend/backend/src/services/project-tickets/ticket-routine-nudge.d.ts +59 -0
- package/dist/backend/backend/src/services/project-tickets/ticket-routine-nudge.d.ts.map +1 -0
- package/dist/backend/backend/src/services/project-tickets/ticket-routine-nudge.js +123 -0
- package/dist/backend/backend/src/services/project-tickets/ticket-routine-nudge.js.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.d.ts +173 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.d.ts.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.js +263 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.js.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.d.ts +35 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.d.ts.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.js +47 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-pulls-weekly.loader.js.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.d.ts +20 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.js +30 -3
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt-data.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.d.ts +7 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.js +8 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.renderer.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.d.ts +39 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.js +93 -2
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.d.ts +13 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.js +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/owner-receipt.types.js.map +1 -1
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.d.ts +110 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.d.ts.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.js +243 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.js.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.loader.d.ts +27 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.loader.d.ts.map +1 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.loader.js +45 -0
- package/dist/backend/backend/src/services/v3/owner-receipt/verified-spot-check.loader.js.map +1 -0
- package/dist/backend/backend/src/types/experiment.types.d.ts +14 -0
- package/dist/backend/backend/src/types/experiment.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/experiment.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +64 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +64 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/frontend/dist/assets/{index-b4db6abe.js → index-19c47468.js} +76 -76
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -22,11 +22,27 @@
|
|
|
22
22
|
// The check asks GitHub through `gh` (CREWLY_GUARD_GH overrides the binary); when it cannot
|
|
23
23
|
// ask, the merge is blocked. CREWLY_GUARD_MERGE_CHECK=0 turns it off.
|
|
24
24
|
//
|
|
25
|
+
// Local gate (private repos whose CI cannot run): a merge that CI would block (a check not green,
|
|
26
|
+
// or none to show the merge works) is accepted when ALL of: the repo has an entry in
|
|
27
|
+
// local-gates.json; every changed file (and every renamed-from path) matches that entry's
|
|
28
|
+
// contentPaths; `record-local-gate` recorded exitCode 0 for exactly the PR head SHA; the record
|
|
29
|
+
// was made against the current base head (or the PR is not behind); and no check really failed
|
|
30
|
+
// (a job that never started because GitHub billing/spending limits stopped it counts as absent).
|
|
31
|
+
// Anything else is blocked as before, and the message says what is missing. The record is a file
|
|
32
|
+
// the agent could write by hand: a speed bump, not a boundary. A repo with "codePrs": true also
|
|
33
|
+
// accepts a PR with non-content files, but only with a TL marker for the same head SHA written by
|
|
34
|
+
// `record-tl-verification` (a team leader other than the session that ran the gate). A PR more than
|
|
35
|
+
// CREWLY_GUARD_MERGE_MAX_BEHIND commits behind is still blocked.
|
|
36
|
+
//
|
|
25
37
|
// Coverage limits: an interpreter one-liner (python -c, node -e), a command built at
|
|
26
38
|
// runtime ($VAR, base64, a script the agent writes then runs) and `$(...)` inside
|
|
27
39
|
// double quotes are NOT seen. A speed bump, not a boundary (same as control-plane-guard).
|
|
28
40
|
|
|
29
41
|
import { execFileSync } from 'node:child_process';
|
|
42
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { dirname, join } from 'node:path';
|
|
45
|
+
import { fileURLToPath } from 'node:url';
|
|
30
46
|
|
|
31
47
|
const PREFIX = 'destructive-git-guard:';
|
|
32
48
|
const PROTECTED_BRANCHES = new Set(['main', 'master']);
|
|
@@ -183,6 +199,135 @@ function checkState(c) {
|
|
|
183
199
|
return { name, green: done && GREEN.has(String(c.conclusion || '').toUpperCase()), at: c.completedAt || c.startedAt };
|
|
184
200
|
}
|
|
185
201
|
|
|
202
|
+
|
|
203
|
+
/** Repos with a local-gate path (config next to this file; CREWLY_GUARD_LOCAL_GATES overrides). */
|
|
204
|
+
const LOCAL_GATES_FILE = process.env.CREWLY_GUARD_LOCAL_GATES || join(dirname(fileURLToPath(import.meta.url)), 'local-gates.json');
|
|
205
|
+
/** Where `record-local-gate` writes its records. */
|
|
206
|
+
const LOCAL_GATES_DIR = join(process.env.CREWLY_HOME || join(homedir(), '.crewly'), 'local-gates');
|
|
207
|
+
/** GitHub's own wording for a job that never started because of billing or the spending limit. */
|
|
208
|
+
const NOT_STARTED_RE = /was not started because/i;
|
|
209
|
+
/** The files endpoint stops at 3000 files; a PR that large is not a content PR. */
|
|
210
|
+
const FILES_CAP = 3000;
|
|
211
|
+
|
|
212
|
+
/** Glob (`**` crosses directories, `*` does not) to an anchored RegExp. */
|
|
213
|
+
function globToRegExp(glob) {
|
|
214
|
+
let re = '';
|
|
215
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
216
|
+
const c = glob[i];
|
|
217
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
218
|
+
if (glob[i + 2] === '/') {
|
|
219
|
+
re += '(?:.*/)?';
|
|
220
|
+
i += 2;
|
|
221
|
+
} else {
|
|
222
|
+
re += '.*';
|
|
223
|
+
i += 1;
|
|
224
|
+
}
|
|
225
|
+
} else if (c === '*') re += '[^/]*';
|
|
226
|
+
else re += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
227
|
+
}
|
|
228
|
+
return new RegExp(`^${re}$`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The local-gates entry for `slug` (owner/name), or null (no file, bad file, no entry). */
|
|
232
|
+
function localGateEntry(slug) {
|
|
233
|
+
try {
|
|
234
|
+
const entry = JSON.parse(readFileSync(LOCAL_GATES_FILE, 'utf8'))?.[slug];
|
|
235
|
+
if (!entry || !Array.isArray(entry.contentPaths) || entry.contentPaths.length === 0) return null;
|
|
236
|
+
return entry;
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* True when a CI check did not run at all: a finished job with no steps whose annotation says GitHub
|
|
244
|
+
* never started it (billing / spending limit). Anything else non-green, including a check we cannot
|
|
245
|
+
* inspect, is a real failure (fail closed). Two cheap `gh api` calls, only for non-green checks.
|
|
246
|
+
*/
|
|
247
|
+
function checkNeverRan(c, api, dir) {
|
|
248
|
+
try {
|
|
249
|
+
const m = /\/actions\/runs\/\d+\/job\/(\d+)/.exec(String(c.detailsUrl || ''));
|
|
250
|
+
if (!m || c.__typename === 'StatusContext' || String(c.status || '').toUpperCase() !== 'COMPLETED') return false;
|
|
251
|
+
if (Number(gh(['api', `${api}/actions/jobs/${m[1]}`, '--jq', '.steps | length'], dir)) !== 0) return false;
|
|
252
|
+
return NOT_STARTED_RE.test(gh(['api', `${api}/check-runs/${m[1]}/annotations`, '--jq', '[.[].message] | join("\n")'], dir));
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* May this merge go ahead on a recorded local green run instead of CI?
|
|
260
|
+
* @returns {{ok: true, note: string} | {ok: false, why: string}}
|
|
261
|
+
*/
|
|
262
|
+
function judgeLocalGate(pr, slug, api, base, behind, realFailures, dir) {
|
|
263
|
+
const entry = localGateEntry(slug);
|
|
264
|
+
if (!entry) return { ok: false, why: `no local-gates entry for ${slug}` };
|
|
265
|
+
if (realFailures.length > 0) return { ok: false, why: `a check really failed (${realFailures.slice(0, 5).join(', ')}); a local run does not override it` };
|
|
266
|
+
const matchers = entry.contentPaths.map(globToRegExp);
|
|
267
|
+
let files;
|
|
268
|
+
try {
|
|
269
|
+
files = gh(['api', '--paginate', `${api}/pulls/${pr.number}/files`, '--jq', '.[] | [.filename, (.previous_filename // "")] | @tsv'], dir)
|
|
270
|
+
.split('\n')
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.map((l) => l.split('\t'));
|
|
273
|
+
} catch {
|
|
274
|
+
return { ok: false, why: 'could not list the PR files to check it is content-only' };
|
|
275
|
+
}
|
|
276
|
+
if (files.length === 0 || files.length >= FILES_CAP) return { ok: false, why: `the PR file list is empty or too long to check (${files.length})` };
|
|
277
|
+
const outside = [...new Set(files.flatMap(([f, prev]) => [f, prev]).filter(Boolean).filter((f) => !matchers.some((re) => re.test(f))))];
|
|
278
|
+
// A code PR (a file outside contentPaths) needs the repo's opt-in `codePrs` plus a TL marker below.
|
|
279
|
+
const codePr = outside.length > 0;
|
|
280
|
+
if (codePr && entry.codePrs !== true) {
|
|
281
|
+
return { ok: false, why: `not content-only, non-content files: ${outside.slice(0, 5).join(', ')}${outside.length > 5 ? `, +${outside.length - 5} more` : ''}` };
|
|
282
|
+
}
|
|
283
|
+
const file = join(LOCAL_GATES_DIR, slug.replace('/', '__'), `pr-${pr.number}-${pr.headRefOid}.json`);
|
|
284
|
+
const fix = `run record-local-gate --repo ${slug} --pr ${pr.number}`;
|
|
285
|
+
if (!existsSync(file)) return { ok: false, why: `no local-gate record at ${String(pr.headRefOid).slice(0, 10)} (${fix})` };
|
|
286
|
+
let rec;
|
|
287
|
+
try {
|
|
288
|
+
rec = JSON.parse(readFileSync(file, 'utf8'));
|
|
289
|
+
} catch {
|
|
290
|
+
return { ok: false, why: `the local-gate record ${file} is unreadable (${fix})` };
|
|
291
|
+
}
|
|
292
|
+
if (rec?.sha !== pr.headRefOid) return { ok: false, why: `the local-gate record is for another commit (${fix})` };
|
|
293
|
+
if (rec.exitCode !== 0) return { ok: false, why: `the local gate at ${String(pr.headRefOid).slice(0, 10)} failed (exit ${rec.exitCode})` };
|
|
294
|
+
if (behind > 0) {
|
|
295
|
+
let baseHead;
|
|
296
|
+
try {
|
|
297
|
+
baseHead = gh(['api', `${api}/commits/${encodeURIComponent(base)}`, '--jq', '.sha'], dir);
|
|
298
|
+
} catch {
|
|
299
|
+
return { ok: false, why: `could not read the head of ${base} to check the record is not stale` };
|
|
300
|
+
}
|
|
301
|
+
if (rec.baseSha !== baseHead) return { ok: false, why: `stale: the record was made against ${String(rec.baseSha).slice(0, 10)} but ${base} is now at ${baseHead.slice(0, 10)} (re-run record-local-gate)` };
|
|
302
|
+
}
|
|
303
|
+
let tl = '';
|
|
304
|
+
if (codePr) {
|
|
305
|
+
const dir2 = join(LOCAL_GATES_DIR, slug.replace('/', '__'));
|
|
306
|
+
const markerFix = `a team leader runs record-tl-verification --repo ${slug} --pr ${pr.number} --summary "<what you checked>"`;
|
|
307
|
+
const markerFile = join(dir2, `pr-${pr.number}-${pr.headRefOid}.tl.json`);
|
|
308
|
+
if (!existsSync(markerFile)) {
|
|
309
|
+
let other = [];
|
|
310
|
+
try {
|
|
311
|
+
other = readdirSync(dir2).filter((f) => f.startsWith(`pr-${pr.number}-`) && f.endsWith('.tl.json'));
|
|
312
|
+
} catch {
|
|
313
|
+
other = [];
|
|
314
|
+
}
|
|
315
|
+
return { ok: false, why: other.length > 0 ? `the TL verification marker is for another commit, not ${String(pr.headRefOid).slice(0, 10)} (${markerFix})` : `code PR (${outside.slice(0, 3).join(', ')}${outside.length > 3 ? ', ...' : ''}): no TL verification marker at ${String(pr.headRefOid).slice(0, 10)} (${markerFix})` };
|
|
316
|
+
}
|
|
317
|
+
let marker;
|
|
318
|
+
try {
|
|
319
|
+
marker = JSON.parse(readFileSync(markerFile, 'utf8'));
|
|
320
|
+
} catch {
|
|
321
|
+
return { ok: false, why: `the TL verification marker ${markerFile} is unreadable (${markerFix})` };
|
|
322
|
+
}
|
|
323
|
+
if (marker?.sha !== pr.headRefOid) return { ok: false, why: `the TL verification marker is for another commit (${markerFix})` };
|
|
324
|
+
if (typeof marker.verifier !== 'string' || marker.verifier === '') return { ok: false, why: 'the TL verification marker names no verifier' };
|
|
325
|
+
if (marker.verifier === rec.recordedBy) return { ok: false, why: `the TL verification marker was written by the session that ran the gate (${marker.verifier}); another team leader must verify` };
|
|
326
|
+
tl = `; TL verified by ${marker.verifier} (gate run by ${rec.recordedBy ?? '?'}): ${String(marker.summary ?? '').slice(0, 120)}`;
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, note: `local gate accepted for PR #${pr.number} in place of CI: record ${file} (head ${String(pr.headRefOid).slice(0, 10)}, exit 0, finished ${rec.finishedAt ?? '?'}, ${files.length} ${codePr ? 'file(s), code PR' : 'content file(s)'})${tl}` };
|
|
329
|
+
}
|
|
330
|
+
|
|
186
331
|
/** @returns {string|null} why `gh pr merge <rest>` is not a verified merge, or null. */
|
|
187
332
|
function judgePrMerge(rest, dir) {
|
|
188
333
|
if (!MERGE_CHECK_ON || rest.includes('--disable-auto')) return null;
|
|
@@ -217,17 +362,31 @@ function judgePrMerge(rest, dir) {
|
|
|
217
362
|
if (!Number.isFinite(behind)) return `could not compare PR #${pr.number} with ${base} on GitHub`;
|
|
218
363
|
const n = pr.number;
|
|
219
364
|
if (behind > MERGE_MAX_BEHIND) return `PR #${n} is ${behind} commits behind ${base} (more than ${MERGE_MAX_BEHIND}); its CI did not run on what the merge would produce`;
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
365
|
+
const rollup = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
|
|
366
|
+
const checks = rollup.map((c) => ({ ...checkState(c), raw: c }));
|
|
367
|
+
const notGreen = checks.filter((c) => !c.green);
|
|
368
|
+
// A CI-state problem (not the behind-limit above): the local gate may stand in for CI.
|
|
369
|
+
let ciReason = null;
|
|
370
|
+
let realFailures = [];
|
|
371
|
+
if (notGreen.length > 0) {
|
|
372
|
+
ciReason = `CI is not green on PR #${n} (${notGreen.slice(0, 5).map((c) => c.name).join(', ')})`;
|
|
373
|
+
realFailures = notGreen.filter((c) => !checkNeverRan(c.raw, api, dir)).map((c) => c.name);
|
|
374
|
+
} else if (behind > 0) {
|
|
375
|
+
if (checks.length === 0) ciReason = `PR #${n} is ${behind} commit(s) behind ${base} and has no CI run to show the merge result works`;
|
|
376
|
+
else {
|
|
377
|
+
const latest = Math.max(...checks.map((c) => Date.parse(c.at || '')).filter((t) => Number.isFinite(t)));
|
|
378
|
+
if (!Number.isFinite(latest) || (Number.isFinite(baseAt) && latest < baseAt)) {
|
|
379
|
+
ciReason = `PR #${n}'s latest CI run predates the head of ${base} (the PR is ${behind} commit(s) behind)`;
|
|
380
|
+
}
|
|
228
381
|
}
|
|
229
382
|
}
|
|
230
|
-
return null;
|
|
383
|
+
if (ciReason === null) return null;
|
|
384
|
+
const local = judgeLocalGate({ number: n, headRefOid: pr.headRefOid }, `${m[1]}/${m[2]}`, api, base, behind, realFailures, dir);
|
|
385
|
+
if (local.ok) {
|
|
386
|
+
process.stdout.write(`${PREFIX} ${local.note}\n`);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
return `${ciReason}; local gate: ${local.why}`;
|
|
231
390
|
}
|
|
232
391
|
|
|
233
392
|
let checked = 0;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Repos whose CI cannot run (private repos, GitHub Actions billing off). For these, `gh pr merge` of a CONTENT-ONLY PR is accepted when `record-local-gate` recorded a green local run at the PR head. A repo without an entry here gets no such path. gate = commands run in order in a fresh clone of the PR head; contentPaths = the files a content PR may touch (glob: ** crosses directories, * stays inside one); codePrs: true = a PR with other files may also merge, but only with a TL verification marker for its head.",
|
|
3
|
+
"stevehuang0115/crewly-web": {
|
|
4
|
+
"gate": [
|
|
5
|
+
"npm ci --ignore-scripts --no-audit --no-fund",
|
|
6
|
+
"npx tsc --noEmit",
|
|
7
|
+
"npx vitest run --maxWorkers=2 --retry=1",
|
|
8
|
+
"npm run build"
|
|
9
|
+
],
|
|
10
|
+
"contentPaths": ["content/blog/**/*.md"],
|
|
11
|
+
"codePrs": true
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Record Local Gate
|
|
3
|
+
description: Run a private repo's gate on a PR's exact head in a fresh clone and record the result, so a content-only PR can merge when GitHub Actions cannot run.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
category: development
|
|
6
|
+
skillType: claude-skill
|
|
7
|
+
assignableRoles:
|
|
8
|
+
- developer
|
|
9
|
+
- fullstack-dev
|
|
10
|
+
- backend-developer
|
|
11
|
+
- frontend-developer
|
|
12
|
+
- generalist
|
|
13
|
+
triggers:
|
|
14
|
+
- record local gate
|
|
15
|
+
- local green build
|
|
16
|
+
- ci cannot run
|
|
17
|
+
- merge content pr without ci
|
|
18
|
+
tags:
|
|
19
|
+
- merge
|
|
20
|
+
- ci
|
|
21
|
+
- guard
|
|
22
|
+
execution:
|
|
23
|
+
type: script
|
|
24
|
+
script:
|
|
25
|
+
file: execute.sh
|
|
26
|
+
interpreter: bash
|
|
27
|
+
timeoutMs: 1800000
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Record Local Gate
|
|
31
|
+
|
|
32
|
+
Some private repos cannot run GitHub Actions (billing is off). `destructive-git-guard` blocks `gh pr merge` unless CI is green, so their PRs could never merge. For a repo listed in `config/hooks/destructive-git-guard/local-gates.json`, the guard accepts a recorded local run instead, for **content-only** PRs.
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bash config/skills/agent/core/record-local-gate/execute.sh --repo stevehuang0115/crewly-web --pr 285
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It reads the PR head SHA with `gh`, checks that exact SHA out in a fresh temporary clone (never your working tree), runs the repo's gate commands with a scrubbed environment, and writes `$CREWLY_HOME/local-gates/<owner>__<repo>/pr-<N>-<sha>.json` (sha, baseRef, baseSha, command, exitCode, start and finish times, last 40 log lines, recordedBy). It prints what it ran and the exit code, and exits non-zero when the gate fails (the failing record is still written). There is no `--exit-code` flag: a record is never handed in.
|
|
41
|
+
|
|
42
|
+
Then `gh pr merge` goes through the guard. The guard accepts only when ALL hold: the repo has an entry; every changed file (and renamed-from path) matches the entry's `contentPaths`; a record exists for exactly the PR head with exit 0; the record's base head is still the base head (or the PR is not behind); no check actually ran and failed. Push a new commit and the record no longer matches: run this again.
|
|
43
|
+
|
|
44
|
+
## Limits
|
|
45
|
+
|
|
46
|
+
The record is a file on disk. It is a speed bump, like the guard itself, not a boundary: someone who can write files can forge it. It does not replace review, and code PRs get no such path.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# record-local-gate — run a repo's gate on a PR's exact head commit in a FRESH clone and
|
|
3
|
+
# record the result for destructive-git-guard (gh pr merge of a content-only PR whose CI
|
|
4
|
+
# cannot run). There is no way to pass an exit code in: the record is only ever the real
|
|
5
|
+
# result of the commands in local-gates.json. See config/hooks/destructive-git-guard/guard.mjs.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
|
|
9
|
+
GH="${CREWLY_GUARD_GH:-gh}"
|
|
10
|
+
GATES="${CREWLY_GUARD_LOCAL_GATES:-${SCRIPT_DIR}/../../../../hooks/destructive-git-guard/local-gates.json}"
|
|
11
|
+
RECORDS_ROOT="${CREWLY_HOME:-$HOME/.crewly}/local-gates"
|
|
12
|
+
LOG_TAIL_LINES=40
|
|
13
|
+
|
|
14
|
+
usage() {
|
|
15
|
+
cat <<'EOF_USAGE'
|
|
16
|
+
Usage: bash execute.sh --repo owner/name --pr N
|
|
17
|
+
Runs the repo's gate (config/hooks/destructive-git-guard/local-gates.json) on the PR head in a
|
|
18
|
+
fresh temporary clone and writes $CREWLY_HOME/local-gates/<owner>__<repo>/pr-<N>-<sha>.json.
|
|
19
|
+
Exits non-zero when the gate fails (the record is still written, with the failing exit code).
|
|
20
|
+
EOF_USAGE
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fail() { printf '{"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" >&2; exit "${2:-1}"; }
|
|
24
|
+
|
|
25
|
+
REPO=""; PR=""
|
|
26
|
+
while [ $# -gt 0 ]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--repo) REPO="${2:-}"; shift 2 ;;
|
|
29
|
+
--pr) PR="${2:-}"; shift 2 ;;
|
|
30
|
+
-h|--help) usage; exit 0 ;;
|
|
31
|
+
*) usage >&2; fail "unknown argument: $1 (this skill takes only --repo and --pr; a record cannot be hand-made)" 2 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
[[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "--repo owner/name is required" 2
|
|
35
|
+
[[ "$PR" =~ ^[0-9]+$ ]] || fail "--pr <number> is required" 2
|
|
36
|
+
command -v jq >/dev/null || fail "jq is required"
|
|
37
|
+
|
|
38
|
+
ENTRY="$(jq -c --arg r "$REPO" '.[$r] // empty' "$GATES" 2>/dev/null)"
|
|
39
|
+
[ -n "$ENTRY" ] || fail "no local-gates entry for $REPO in $GATES; this repo has no local-gate path (its CI is the only way)"
|
|
40
|
+
N_CMDS="$(printf '%s' "$ENTRY" | jq '.gate | length')"
|
|
41
|
+
[ "${N_CMDS:-0}" -gt 0 ] || fail "the local-gates entry for $REPO has no gate commands"
|
|
42
|
+
|
|
43
|
+
VIEW="$("$GH" pr view "$PR" -R "$REPO" --json headRefOid,baseRefName 2>&1)" || fail "could not read PR #$PR with gh: $VIEW"
|
|
44
|
+
SHA="$(printf '%s' "$VIEW" | jq -r .headRefOid)"
|
|
45
|
+
BASE_REF="$(printf '%s' "$VIEW" | jq -r .baseRefName)"
|
|
46
|
+
[[ "$SHA" =~ ^[0-9a-f]{7,64}$ ]] || fail "gh returned no head commit for PR #$PR"
|
|
47
|
+
BASE_SHA="$("$GH" api "repos/$REPO/commits/$BASE_REF" --jq .sha 2>&1)" || fail "could not read the head of $BASE_REF: $BASE_SHA"
|
|
48
|
+
|
|
49
|
+
WORK="$(mktemp -d "${TMPDIR:-/tmp}/local-gate.XXXXXX")" || fail "could not make a temp dir"
|
|
50
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
51
|
+
CLONE="$WORK/clone"; LOG="$WORK/gate.log"; : > "$LOG"
|
|
52
|
+
URL="${CREWLY_LOCAL_GATE_CLONE_URL:-https://github.com/$REPO.git}"
|
|
53
|
+
|
|
54
|
+
git init -q "$CLONE" || fail "git init failed"
|
|
55
|
+
(
|
|
56
|
+
cd "$CLONE" || exit 1
|
|
57
|
+
git remote add origin "$URL"
|
|
58
|
+
git -c credential.helper= -c "credential.helper=!$GH auth git-credential" fetch -q --depth 1 origin "pull/$PR/head" \
|
|
59
|
+
&& [ "$(git rev-parse FETCH_HEAD)" = "$SHA" ] \
|
|
60
|
+
&& git checkout -q --detach FETCH_HEAD
|
|
61
|
+
) >"$WORK/fetch.log" 2>&1 || fail "could not check out PR #$PR at $SHA in a fresh clone (the PR may have moved): $(tail -3 "$WORK/fetch.log" | tr '\n' ' ')"
|
|
62
|
+
|
|
63
|
+
echo "record-local-gate: $REPO PR #$PR at $SHA (base $BASE_REF $BASE_SHA), $N_CMDS gate command(s), fresh clone $CLONE"
|
|
64
|
+
STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
65
|
+
EXIT_CODE=0; RAN=""
|
|
66
|
+
while IFS= read -r CMD; do
|
|
67
|
+
[ -n "$CMD" ] || continue
|
|
68
|
+
RAN="${RAN:+$RAN && }$CMD"
|
|
69
|
+
echo "record-local-gate: running: $CMD"
|
|
70
|
+
# A scrubbed environment: no tokens or session secrets reach the repo's scripts.
|
|
71
|
+
(cd "$CLONE" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" LANG="${LANG:-en_US.UTF-8}" CI=true bash -c "set -o pipefail; $CMD") 2>&1 | tee -a "$LOG"
|
|
72
|
+
RC="${PIPESTATUS[0]}"
|
|
73
|
+
if [ "$RC" -ne 0 ]; then EXIT_CODE="$RC"; echo "record-local-gate: FAILED (exit $RC): $CMD"; break; fi
|
|
74
|
+
done < <(printf '%s' "$ENTRY" | jq -r '.gate[]')
|
|
75
|
+
FINISHED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
76
|
+
|
|
77
|
+
DIR="$RECORDS_ROOT/${REPO/\//__}"
|
|
78
|
+
mkdir -p "$DIR" || fail "could not create $DIR"
|
|
79
|
+
OUT="$DIR/pr-$PR-$SHA.json"
|
|
80
|
+
tail -n "$LOG_TAIL_LINES" "$LOG" | jq -Rs --arg sha "$SHA" --arg baseRef "$BASE_REF" --arg baseSha "$BASE_SHA" --arg command "$RAN" \
|
|
81
|
+
--argjson exitCode "$EXIT_CODE" --arg startedAt "$STARTED" --arg finishedAt "$FINISHED" --arg by "${CREWLY_SESSION_NAME:-unknown}" \
|
|
82
|
+
'{sha:$sha, baseRef:$baseRef, baseSha:$baseSha, command:$command, exitCode:$exitCode, startedAt:$startedAt, finishedAt:$finishedAt, logTail:(split("\n")|map(select(length>0))), recordedBy:$by}' \
|
|
83
|
+
> "$OUT.tmp" && mv "$OUT.tmp" "$OUT" || fail "could not write the record $OUT"
|
|
84
|
+
|
|
85
|
+
echo "record-local-gate: ran $RAN"
|
|
86
|
+
echo "record-local-gate: exit code $EXIT_CODE; record: $OUT"
|
|
87
|
+
exit "$EXIT_CODE"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for record-local-gate. Offline: `gh` is a stub, the "remote" is a local repo with
|
|
3
|
+
# refs/pull/7/head, and the gate commands are `true` / `false`.
|
|
4
|
+
set -uo pipefail
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
PASS=0; FAIL=0
|
|
7
|
+
check() {
|
|
8
|
+
if [ "$2" = "$3" ]; then PASS=$((PASS + 1)); echo " ✓ $1"
|
|
9
|
+
else FAIL=$((FAIL + 1)); echo " ✗ $1"; echo " expected: $2"; echo " actual: $3"; fi
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
13
|
+
REMOTE="$T/remote"
|
|
14
|
+
git init -q -b main "$REMOTE"
|
|
15
|
+
git -C "$REMOTE" -c user.name=t -c user.email=t@t commit -q --allow-empty -m base
|
|
16
|
+
git -C "$REMOTE" -c user.name=t -c user.email=t@t commit -q --allow-empty -m pr
|
|
17
|
+
SHA="$(git -C "$REMOTE" rev-parse HEAD)"
|
|
18
|
+
git -C "$REMOTE" update-ref refs/pull/7/head "$SHA"
|
|
19
|
+
|
|
20
|
+
# gh stub: pr view -> head sha + base; api commits/main --jq .sha -> base sha
|
|
21
|
+
cat > "$T/gh" <<EOF
|
|
22
|
+
#!/bin/bash
|
|
23
|
+
if [ "\$1" = pr ] && [ "\$2" = view ]; then echo '{"headRefOid":"'"\${PR_SHA:-$SHA}"'","baseRefName":"main"}'; exit 0; fi
|
|
24
|
+
if [ "\$1" = api ]; then echo basesha0001; exit 0; fi
|
|
25
|
+
exit 3
|
|
26
|
+
EOF
|
|
27
|
+
chmod +x "$T/gh"
|
|
28
|
+
|
|
29
|
+
gates() { printf '{"o/r":{"gate":%s,"contentPaths":["content/**/*.md"]}}' "$1" > "$T/gates.json"; }
|
|
30
|
+
export CREWLY_GUARD_GH="$T/gh" CREWLY_GUARD_LOCAL_GATES="$T/gates.json" CREWLY_HOME="$T/home" \
|
|
31
|
+
CREWLY_LOCAL_GATE_CLONE_URL="$REMOTE" CREWLY_SESSION_NAME=tester TMPDIR="$T"
|
|
32
|
+
REC="$T/home/local-gates/o__r/pr-7-$SHA.json"
|
|
33
|
+
run() { bash "$SCRIPT_DIR/execute.sh" "$@" >"$T/out" 2>"$T/err"; echo $?; }
|
|
34
|
+
|
|
35
|
+
echo "record-local-gate"
|
|
36
|
+
gates '["true","echo second"]'
|
|
37
|
+
check "passing gate exits 0" 0 "$(run --repo o/r --pr 7)"
|
|
38
|
+
check "record exists for the head sha" yes "$([ -f "$REC" ] && echo yes || echo no)"
|
|
39
|
+
check "record sha is the PR head" "$SHA" "$(jq -r .sha "$REC")"
|
|
40
|
+
check "record exitCode 0" 0 "$(jq .exitCode "$REC")"
|
|
41
|
+
check "record keeps the base sha at run time" basesha0001 "$(jq -r .baseSha "$REC")"
|
|
42
|
+
check "record names who recorded it" tester "$(jq -r .recordedBy "$REC")"
|
|
43
|
+
check "record lists the commands run" "true && echo second" "$(jq -r .command "$REC")"
|
|
44
|
+
check "output says what ran and the exit code" 1 "$(grep -c 'exit code 0; record:' "$T/out")"
|
|
45
|
+
|
|
46
|
+
rm -f "$REC"
|
|
47
|
+
gates '["true","false","echo never"]'
|
|
48
|
+
check "failing gate exits non-zero" 1 "$(run --repo o/r --pr 7)"
|
|
49
|
+
check "failing gate still writes a record" yes "$([ -f "$REC" ] && echo yes || echo no)"
|
|
50
|
+
check "failing record has exitCode != 0" 1 "$(jq .exitCode "$REC")"
|
|
51
|
+
check "gate stops at the first failure" 0 "$(grep -c '^never' "$T/out")"
|
|
52
|
+
|
|
53
|
+
rm -f "$REC"
|
|
54
|
+
gates '["true"]'
|
|
55
|
+
check "--exit-code is refused" 2 "$(run --repo o/r --pr 7 --exit-code 0)"
|
|
56
|
+
check "no record after a refused flag" no "$([ -f "$REC" ] && echo yes || echo no)"
|
|
57
|
+
check "a repo with no entry is refused" 1 "$(run --repo x/y --pr 7)"
|
|
58
|
+
check "a missing --pr is refused" 2 "$(run --repo o/r)"
|
|
59
|
+
|
|
60
|
+
check "a PR that moved since gh looked is refused" 1 "$(PR_SHA=deadbeefdeadbeef run --repo o/r --pr 7)"
|
|
61
|
+
check "no record for the moved head" 0 "$(find "$T/home" -name 'pr-7-deadbeef*' 2>/dev/null | wc -l | tr -d ' ')"
|
|
62
|
+
|
|
63
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
64
|
+
[ "$FAIL" -eq 0 ] && [ "$PASS" -gt 0 ]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Record TL Verification
|
|
3
|
+
description: A team leader's marker that a code PR in a CI-less private repo was verified at its exact head; the merge guard needs it next to a green local gate record.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
category: development
|
|
6
|
+
skillType: claude-skill
|
|
7
|
+
assignableRoles:
|
|
8
|
+
- team-leader
|
|
9
|
+
triggers:
|
|
10
|
+
- record tl verification
|
|
11
|
+
- verify code pr without ci
|
|
12
|
+
- tl marker for merge
|
|
13
|
+
tags:
|
|
14
|
+
- merge
|
|
15
|
+
- guard
|
|
16
|
+
- review
|
|
17
|
+
execution:
|
|
18
|
+
type: script
|
|
19
|
+
script:
|
|
20
|
+
file: execute.sh
|
|
21
|
+
interpreter: bash
|
|
22
|
+
timeoutMs: 60000
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Record TL Verification
|
|
26
|
+
|
|
27
|
+
For a repo with `"codePrs": true` in `config/hooks/destructive-git-guard/local-gates.json` (today: crewly-web), a PR with non-content files merges without CI only when (1) `record-local-gate` recorded a passing run at the PR head and (2) a team leader verified that same head. This skill is (2).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bash config/skills/agent/core/record-tl-verification/execute.sh --repo stevehuang0115/crewly-web --pr 266 --summary "read the diff, gate green at head, behaviour checked"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It refuses unless: the repo is enabled for code PRs; your session has role `team-leader` in a team config; a passing gate record exists at the PR's current head; and you are not the session that ran that gate (GitHub cannot tell agents apart, so the gate runner stands in for the author). It writes `pr-<N>-<sha>.tl.json` with the SHA, your session, the gate runner and your one-line summary; the guard prints both sessions when it accepts.
|
|
34
|
+
|
|
35
|
+
A new commit changes the head: gate and marker must both be redone. Verify by reading the diff and the gate result, not by trusting the author's report.
|
|
36
|
+
|
|
37
|
+
## Limits
|
|
38
|
+
|
|
39
|
+
The marker is a file, so like the guard it is a speed bump, not a boundary: the role check reads team configs and `CREWLY_SESSION_NAME`, which a determined session could fake.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# record-tl-verification — a team leader's verification marker for a CODE PR in a repo whose CI
|
|
3
|
+
# cannot run. destructive-git-guard needs it, next to a green record-local-gate record, before it
|
|
4
|
+
# lets `gh pr merge` through (local-gates.json "codePrs": true). The caller must be a team leader
|
|
5
|
+
# (role "team-leader" in a team config under ~/.crewly/teams) and must not be the session that ran
|
|
6
|
+
# the gate. A marker is for one head SHA: a new commit needs a new verification.
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
|
|
10
|
+
GH="${CREWLY_GUARD_GH:-gh}"
|
|
11
|
+
GATES="${CREWLY_GUARD_LOCAL_GATES:-${SCRIPT_DIR}/../../../../hooks/destructive-git-guard/local-gates.json}"
|
|
12
|
+
RECORDS_ROOT="${CREWLY_HOME:-$HOME/.crewly}/local-gates"
|
|
13
|
+
TEAMS_DIR="${CREWLY_TEAMS_DIR:-$HOME/.crewly/teams}"
|
|
14
|
+
|
|
15
|
+
usage() {
|
|
16
|
+
cat <<'EOF_USAGE'
|
|
17
|
+
Usage: bash execute.sh --repo owner/name --pr N --summary "one line: what you verified"
|
|
18
|
+
Team leaders only. Needs a passing record-local-gate record at the PR's current head, made by
|
|
19
|
+
another session. Writes $CREWLY_HOME/local-gates/<owner>__<repo>/pr-<N>-<sha>.tl.json.
|
|
20
|
+
EOF_USAGE
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fail() { printf '{"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" >&2; exit "${2:-1}"; }
|
|
24
|
+
|
|
25
|
+
REPO=""; PR=""; SUMMARY=""
|
|
26
|
+
while [ $# -gt 0 ]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--repo) REPO="${2:-}"; shift 2 ;;
|
|
29
|
+
--pr) PR="${2:-}"; shift 2 ;;
|
|
30
|
+
--summary) SUMMARY="${2:-}"; shift 2 ;;
|
|
31
|
+
-h|--help) usage; exit 0 ;;
|
|
32
|
+
*) usage >&2; fail "unknown argument: $1" 2 ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
[[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "--repo owner/name is required" 2
|
|
36
|
+
[[ "$PR" =~ ^[0-9]+$ ]] || fail "--pr <number> is required" 2
|
|
37
|
+
[ -n "$SUMMARY" ] || fail "--summary \"what you verified\" is required (one line)" 2
|
|
38
|
+
command -v jq >/dev/null || fail "jq is required"
|
|
39
|
+
|
|
40
|
+
jq -e --arg r "$REPO" '.[$r].codePrs == true' "$GATES" >/dev/null 2>&1 \
|
|
41
|
+
|| fail "$REPO is not enabled for code PRs (no \"codePrs\": true in $GATES); a TL marker would be ignored"
|
|
42
|
+
|
|
43
|
+
SESSION="${CREWLY_SESSION_NAME:-}"
|
|
44
|
+
[ -n "$SESSION" ] || fail "CREWLY_SESSION_NAME is not set; the verifier cannot be identified"
|
|
45
|
+
ROLE=""; CONFIGS=0
|
|
46
|
+
for c in "$TEAMS_DIR"/*/config.json; do
|
|
47
|
+
[ -f "$c" ] || continue
|
|
48
|
+
CONFIGS=$((CONFIGS + 1))
|
|
49
|
+
r="$(jq -r --arg s "$SESSION" '[.members[]? | select(.sessionName == $s) | .role] | first // empty' "$c" 2>/dev/null)"
|
|
50
|
+
[ -n "$r" ] && { ROLE="$r"; break; }
|
|
51
|
+
done
|
|
52
|
+
[ "$CONFIGS" -gt 0 ] || fail "no team configs found under $TEAMS_DIR (0 checked); cannot confirm $SESSION is a team leader"
|
|
53
|
+
[ "$ROLE" = "team-leader" ] || fail "only a team leader may record a TL verification ($SESSION has role '${ROLE:-unknown}')"
|
|
54
|
+
|
|
55
|
+
SHA="$("$GH" pr view "$PR" -R "$REPO" --json headRefOid --jq .headRefOid 2>&1)" || fail "could not read PR #$PR with gh: $SHA"
|
|
56
|
+
[[ "$SHA" =~ ^[0-9a-f]{7,64}$ ]] || fail "gh returned no head commit for PR #$PR"
|
|
57
|
+
|
|
58
|
+
DIR="$RECORDS_ROOT/${REPO/\//__}"
|
|
59
|
+
REC="$DIR/pr-$PR-$SHA.json"
|
|
60
|
+
[ -f "$REC" ] || fail "no local-gate record at ${SHA:0:10}; run record-local-gate --repo $REPO --pr $PR first"
|
|
61
|
+
[ "$(jq -r '.sha' "$REC")" = "$SHA" ] && [ "$(jq -r '.exitCode' "$REC")" = "0" ] || fail "the local-gate record at ${SHA:0:10} is not a passing run for this head"
|
|
62
|
+
RECORDER="$(jq -r '.recordedBy // empty' "$REC")"
|
|
63
|
+
[ "$RECORDER" != "$SESSION" ] || fail "$SESSION ran the gate for this PR; another team leader must verify it"
|
|
64
|
+
|
|
65
|
+
OUT="$DIR/pr-$PR-$SHA.tl.json"
|
|
66
|
+
jq -n --arg sha "$SHA" --arg verifier "$SESSION" --arg role "$ROLE" --arg recorder "$RECORDER" --arg summary "$SUMMARY" \
|
|
67
|
+
--arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
68
|
+
'{sha:$sha, verifier:$verifier, role:$role, gateRecordedBy:$recorder, summary:$summary, recordedAt:$at}' > "$OUT.tmp" \
|
|
69
|
+
&& mv "$OUT.tmp" "$OUT" || fail "could not write $OUT"
|
|
70
|
+
|
|
71
|
+
echo "record-tl-verification: $SESSION (team-leader) verified $REPO PR #$PR at $SHA; gate run by ${RECORDER:-unknown}; marker: $OUT"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for record-tl-verification. Offline: `gh` is a stub, team configs and records are temp files.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
PASS=0; FAIL=0
|
|
6
|
+
check() {
|
|
7
|
+
if [ "$2" = "$3" ]; then PASS=$((PASS + 1)); echo " ✓ $1"
|
|
8
|
+
else FAIL=$((FAIL + 1)); echo " ✗ $1"; echo " expected: $2"; echo " actual: $3"; fi
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
12
|
+
SHA=0123456789abcdef0123456789abcdef01234567
|
|
13
|
+
cat > "$T/gh" <<EOF
|
|
14
|
+
#!/bin/bash
|
|
15
|
+
echo "\${PR_SHA:-$SHA}"
|
|
16
|
+
EOF
|
|
17
|
+
chmod +x "$T/gh"
|
|
18
|
+
mkdir -p "$T/teams/t1" "$T/home/local-gates/o__r"
|
|
19
|
+
printf '{"members":[{"sessionName":"tl1","role":"team-leader"},{"sessionName":"tl2","role":"team-leader"},{"sessionName":"dev1","role":"developer"}]}' > "$T/teams/t1/config.json"
|
|
20
|
+
printf '{"o/r":{"gate":["true"],"contentPaths":["c/**"],"codePrs":true},"o/nocode":{"gate":["true"],"contentPaths":["c/**"]}}' > "$T/gates.json"
|
|
21
|
+
REC="$T/home/local-gates/o__r/pr-9-$SHA.json"
|
|
22
|
+
MARK="$T/home/local-gates/o__r/pr-9-$SHA.tl.json"
|
|
23
|
+
rec() { printf '{"sha":"%s","exitCode":%s,"recordedBy":"dev1"}' "${2:-$SHA}" "${1:-0}" > "$REC"; }
|
|
24
|
+
export CREWLY_GUARD_GH="$T/gh" CREWLY_GUARD_LOCAL_GATES="$T/gates.json" CREWLY_HOME="$T/home" CREWLY_TEAMS_DIR="$T/teams"
|
|
25
|
+
run() { local who="$1"; shift; CREWLY_SESSION_NAME="$who" bash "$SCRIPT_DIR/execute.sh" "$@" >"$T/out" 2>"$T/err"; echo $?; }
|
|
26
|
+
has() { [ -f "$MARK" ] && echo yes || echo no; }
|
|
27
|
+
|
|
28
|
+
echo "record-tl-verification"
|
|
29
|
+
rec 0
|
|
30
|
+
check "a team leader other than the gate runner verifies" 0 "$(run tl1 --repo o/r --pr 9 --summary 'read the diff')"
|
|
31
|
+
check "marker written" yes "$(has)"
|
|
32
|
+
check "marker sha is the head" "$SHA" "$(jq -r .sha "$MARK")"
|
|
33
|
+
check "marker names the verifier" tl1 "$(jq -r .verifier "$MARK")"
|
|
34
|
+
check "marker names the gate runner" dev1 "$(jq -r .gateRecordedBy "$MARK")"
|
|
35
|
+
check "marker keeps the summary" "read the diff" "$(jq -r .summary "$MARK")"
|
|
36
|
+
|
|
37
|
+
rm -f "$MARK"
|
|
38
|
+
check "a developer is refused" 1 "$(run dev1 --repo o/r --pr 9 --summary x)"
|
|
39
|
+
check "an unknown session is refused" 1 "$(run nobody --repo o/r --pr 9 --summary x)"
|
|
40
|
+
check "no marker after refusals" no "$(has)"
|
|
41
|
+
rec 0; printf '{"sha":"%s","exitCode":0,"recordedBy":"tl1"}' "$SHA" > "$REC"
|
|
42
|
+
check "the session that ran the gate is refused" 1 "$(run tl1 --repo o/r --pr 9 --summary x)"
|
|
43
|
+
check "another leader may still verify that gate" 0 "$(run tl2 --repo o/r --pr 9 --summary x)"
|
|
44
|
+
rm -f "$MARK"
|
|
45
|
+
rec 1
|
|
46
|
+
check "a failed gate record is refused" 1 "$(run tl1 --repo o/r --pr 9 --summary x)"
|
|
47
|
+
rm -f "$REC"
|
|
48
|
+
check "no gate record is refused" 1 "$(run tl1 --repo o/r --pr 9 --summary x)"
|
|
49
|
+
rec 0
|
|
50
|
+
check "a repo without codePrs is refused" 1 "$(run tl1 --repo o/nocode --pr 9 --summary x)"
|
|
51
|
+
check "a missing summary is refused" 2 "$(run tl1 --repo o/r --pr 9)"
|
|
52
|
+
check "a PR whose head moved has no record to verify" 1 "$(PR_SHA=feedfacefeedface run tl1 --repo o/r --pr 9 --summary x)"
|
|
53
|
+
check "no marker for the moved head" 0 "$(find "$T/home" -name '*feedface*' | wc -l | tr -d ' ')"
|
|
54
|
+
|
|
55
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
56
|
+
[ "$FAIL" -eq 0 ] && [ "$PASS" -gt 0 ]
|