canary-test-cli 7.2.0 → 8.0.0
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/agents/skills/README.md +23 -4
- package/agents/skills/claude-code/canary-batwoman/SKILL.md +119 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +23 -16
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +3 -1
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +20 -3
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +1 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +15 -0
- package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
- package/agents/skills/lib/parse-args.mjs +200 -139
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +46 -7
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +13 -18
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/vacuity-scanner.js +151 -6
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/cli.js +180 -264
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +262 -430
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +48 -32
- package/dist/engine/workflow-cli.js +85 -65
- package/package.json +1 -1
|
@@ -375,13 +375,31 @@ function braceDepths(code) {
|
|
|
375
375
|
function declEnd(code, matches, i, depth) {
|
|
376
376
|
if (depth === null)
|
|
377
377
|
return matches[i + 1]?.index ?? code.length;
|
|
378
|
-
const
|
|
378
|
+
const m = matches[i];
|
|
379
|
+
const own = depth[m.index] ?? 0;
|
|
380
|
+
let sibling = code.length;
|
|
379
381
|
for (let j = i + 1; j < matches.length; j += 1) {
|
|
380
382
|
const at = matches[j].index;
|
|
381
|
-
if ((depth[at] ?? 0) <= own)
|
|
382
|
-
|
|
383
|
+
if ((depth[at] ?? 0) <= own) {
|
|
384
|
+
sibling = at;
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// A `function` owns the text up to its next sibling. A `const`/`let`/`var`
|
|
389
|
+
// owns only its initializer (#871): inside a test body there is usually no
|
|
390
|
+
// later sibling, so bounding a bystander at one let it absorb the target
|
|
391
|
+
// call below it and read as reaching the target, silencing VAC-003. The
|
|
392
|
+
// statement ends at the first `;` or line break at the declaration's own
|
|
393
|
+
// brace depth, so an arrow helper's braced body still belongs to it.
|
|
394
|
+
if (m[1])
|
|
395
|
+
return sibling;
|
|
396
|
+
const from = m.index + m[0].length;
|
|
397
|
+
for (let k = from; k < sibling; k += 1) {
|
|
398
|
+
const ch = code[k];
|
|
399
|
+
if ((ch === ';' || ch === '\n') && (depth[k] ?? 0) <= own)
|
|
400
|
+
return k;
|
|
383
401
|
}
|
|
384
|
-
return
|
|
402
|
+
return sibling;
|
|
385
403
|
}
|
|
386
404
|
/**
|
|
387
405
|
* Grow `targets` with local declarations that themselves reach a target, to a
|
|
@@ -513,6 +531,7 @@ function scanBlock(code, block, file, python, reaching, annotated, skipped, outO
|
|
|
513
531
|
...tautologies(lines, block, file, python),
|
|
514
532
|
...targetNeverInvoked(block, file, reaching, annotated, outOfBand),
|
|
515
533
|
...absenceOnly(lines, block, file, python, targets, skipped),
|
|
534
|
+
...presenceOnBystander(lines, block, file, python, targets),
|
|
516
535
|
];
|
|
517
536
|
}
|
|
518
537
|
/**
|
|
@@ -580,6 +599,20 @@ function targetNeverInvoked(block, file, reaching, annotated, outOfBand) {
|
|
|
580
599
|
* the write. Reported at the first absence assertion, the line an author adds a
|
|
581
600
|
* precondition next to.
|
|
582
601
|
*/
|
|
602
|
+
/**
|
|
603
|
+
* A test title safe to put in a skip label: its first line, capped at 80.
|
|
604
|
+
*
|
|
605
|
+
* The title parser can mis-read a declaration and hand back the source after
|
|
606
|
+
* it (#860 -- whole `describe` bodies reached the summary line). Bounding the
|
|
607
|
+
* label here means a parser slip degrades to a truncated name, never a flood.
|
|
608
|
+
*/
|
|
609
|
+
export function boundedTitle(name) {
|
|
610
|
+
const first = name.split('\n', 1)[0].trim();
|
|
611
|
+
return first.length > 80 ? `${first.slice(0, 80)}…` : first;
|
|
612
|
+
}
|
|
613
|
+
function skipLabel(rules, file, block) {
|
|
614
|
+
return `${file}:${block.line} ${rules} (${boundedTitle(block.name)})`;
|
|
615
|
+
}
|
|
583
616
|
function absenceOnly(lines, block, file, python, targets, skipped) {
|
|
584
617
|
if (targets === null)
|
|
585
618
|
return [];
|
|
@@ -592,7 +625,7 @@ function absenceOnly(lines, block, file, python, targets, skipped) {
|
|
|
592
625
|
// assertion style is one the vocabulary does not know. Both are "cannot
|
|
593
626
|
// verify", so both are recorded rather than passed over in silence.
|
|
594
627
|
skipped.push({
|
|
595
|
-
name:
|
|
628
|
+
name: skipLabel('VAC-003', file, block),
|
|
596
629
|
reason: 'no recognised assertion, so absence-only could not be judged -- the test may assert nothing (LINT-006) or use an unrecognised assertion style',
|
|
597
630
|
});
|
|
598
631
|
return [];
|
|
@@ -605,6 +638,118 @@ function absenceOnly(lines, block, file, python, targets, skipped) {
|
|
|
605
638
|
mk(file, assertions[0].line, 'VAC-003', 'warning', block.name, 'Every assertion in this test asserts an absence, and none of them observes the target.', 'Add one assertion proving the operation actually ran (exit code, returned value, a positive existence) -- otherwise the test passes identically when the code crashed before doing anything.'),
|
|
606
639
|
];
|
|
607
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* Presence matchers that a value the test built for itself satisfies before the
|
|
643
|
+
* target ever runs. The negated-null forms belong here too, but on their own they
|
|
644
|
+
* also match `ABSENCE_ASSERTION` (via `.not.to*`), so an all-negated test stays
|
|
645
|
+
* VAC-003's -- see {@link presenceOnBystander}.
|
|
646
|
+
*/
|
|
647
|
+
const TRIVIAL_PRESENCE_JS = /\.toBeDefined\s*\(\s*\)|\.toBeTruthy\s*\(\s*\)|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toBeFalsy)\s*\(\s*\)/;
|
|
648
|
+
const TRIVIAL_PRESENCE_PY = /^assert\s+([A-Za-z_][\w.]*?)(?:\s+is\s+not\s+None)?\s*(?:,|$)/;
|
|
649
|
+
const SUBJECT_ROOT = /^([A-Za-z_$][\w$]*)(?:\.[A-Za-z_$][\w$]*)*$/;
|
|
650
|
+
function subjectRoot(expr) {
|
|
651
|
+
return expr ? (SUBJECT_ROOT.exec(normalize(expr))?.[1] ?? null) : null;
|
|
652
|
+
}
|
|
653
|
+
/** The root identifier a trivial presence assertion observes, or null. */
|
|
654
|
+
function presenceSubject(text, python) {
|
|
655
|
+
const t = text.trim();
|
|
656
|
+
if (python)
|
|
657
|
+
return subjectRoot(TRIVIAL_PRESENCE_PY.exec(t)?.[1]);
|
|
658
|
+
return TRIVIAL_PRESENCE_JS.test(t) ? subjectRoot(expectArgument(t)) : null;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Every binding of a name inside the body: `const x = rhs` / `x = rhs` (JS) or
|
|
662
|
+
* `x = rhs` (pytest), with the RHS bounded to its own line.
|
|
663
|
+
*
|
|
664
|
+
* Deliberately NOT `closeOverLocals`: that bounds a declaration at its next
|
|
665
|
+
* sibling, so a `const subs = [...]` inside a test absorbs the rest of the test
|
|
666
|
+
* -- including the target call -- and `subs` reads as reaching the target. That
|
|
667
|
+
* is exactly the bystander this rule exists to see.
|
|
668
|
+
*/
|
|
669
|
+
function bodyBindings(lines, python) {
|
|
670
|
+
const re = python
|
|
671
|
+
? /^\s*([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/
|
|
672
|
+
: /^\s*(const\s+|let\s+|var\s+)?([A-Za-z_$][\w$]*)\s*=(?!=)\s*(.*)$/;
|
|
673
|
+
const out = [];
|
|
674
|
+
for (const { text } of lines) {
|
|
675
|
+
const m = re.exec(text);
|
|
676
|
+
if (!m)
|
|
677
|
+
continue;
|
|
678
|
+
if (python)
|
|
679
|
+
out.push({ name: m[1], rhs: m[2], declared: true });
|
|
680
|
+
else
|
|
681
|
+
out.push({ name: m[2], rhs: m[3], declared: m[1] !== undefined });
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
/** Is `rhs` a whole statement on its line? A dangling bracket means it is not. */
|
|
686
|
+
function balanced(rhs) {
|
|
687
|
+
let d = 0;
|
|
688
|
+
for (const c of rhs) {
|
|
689
|
+
if (c === '(' || c === '[' || c === '{')
|
|
690
|
+
d += 1;
|
|
691
|
+
else if (c === ')' || c === ']' || c === '}')
|
|
692
|
+
d -= 1;
|
|
693
|
+
}
|
|
694
|
+
return d === 0;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* VAC-005 -- every assertion is a trivially true presence check, and its subject
|
|
698
|
+
* is a BYSTANDER: a name the test itself bound, never from anything reaching the
|
|
699
|
+
* target (#870). The mirror image of VAC-003:
|
|
700
|
+
*
|
|
701
|
+
* ```js
|
|
702
|
+
* const subs = [{ id: 's1' }];
|
|
703
|
+
* matchSubmissions([], subs);
|
|
704
|
+
* expect(subs).toBeDefined(); // true before the call, true after it
|
|
705
|
+
* ```
|
|
706
|
+
*
|
|
707
|
+
* The bystander clause is the rule, for the same reason VAC-003's is:
|
|
708
|
+
* `const r = target(); expect(r).toBeDefined()` DOES observe the target, and a
|
|
709
|
+
* throw from it would fail the test. Anything the rule cannot prove is a
|
|
710
|
+
* bystander -- a name bound in a hook or at module scope, a multi-line RHS --
|
|
711
|
+
* yields no finding, so the error direction is always a miss, never a false
|
|
712
|
+
* accusation.
|
|
713
|
+
*/
|
|
714
|
+
function presenceOnBystander(lines, block, file, python, targets) {
|
|
715
|
+
if (targets === null)
|
|
716
|
+
return [];
|
|
717
|
+
const anyAssertion = python ? PY_ASSERTION : JS_ASSERTION;
|
|
718
|
+
const absence = python ? PY_ABSENCE_ASSERTION : ABSENCE_ASSERTION;
|
|
719
|
+
const assertions = lines.filter((l) => anyAssertion.test(l.text));
|
|
720
|
+
if (assertions.length === 0)
|
|
721
|
+
return [];
|
|
722
|
+
// All-absence (the negated-null forms included) is VAC-003's to report.
|
|
723
|
+
if (assertions.every((l) => absence.test(l.text)))
|
|
724
|
+
return [];
|
|
725
|
+
const subjects = assertions.map((l) => presenceSubject(l.text, python));
|
|
726
|
+
if (subjects.some((s) => s === null))
|
|
727
|
+
return [];
|
|
728
|
+
const bindings = bodyBindings(lines, python);
|
|
729
|
+
if (bindings.some((b) => !balanced(b.rhs)))
|
|
730
|
+
return [];
|
|
731
|
+
// The body's own names are removed first -- `closeOverLocals` over-attributes
|
|
732
|
+
// them (see `bodyBindings`) -- then re-derived here from their real RHS, to a
|
|
733
|
+
// fixpoint so `const r = save(); const s = r;` still reaches.
|
|
734
|
+
const bound = new Set(bindings.map((b) => b.name));
|
|
735
|
+
const reaching = new Set([...targets].filter((t) => !bound.has(t)));
|
|
736
|
+
let grew = true;
|
|
737
|
+
while (grew) {
|
|
738
|
+
grew = false;
|
|
739
|
+
for (const b of bindings) {
|
|
740
|
+
if (reaching.has(b.name) || !mentionsAny(b.rhs, reaching))
|
|
741
|
+
continue;
|
|
742
|
+
reaching.add(b.name);
|
|
743
|
+
grew = true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const bystander = (name) => !reaching.has(name) && bindings.some((b) => b.name === name && b.declared);
|
|
747
|
+
if (!subjects.every((s) => bystander(s)))
|
|
748
|
+
return [];
|
|
749
|
+
return [
|
|
750
|
+
mk(file, assertions[0].line, 'VAC-005', 'warning', block.name, `Every assertion is a presence check on \`${subjects[0]}\`, a value the test built itself; it holds whether or not the target ran.`, 'Assert on what the target returns or changes -- e.g. `expect(target(input)).toEqual(expected)` -- so the test fails when the target is wrong.'),
|
|
751
|
+
];
|
|
752
|
+
}
|
|
608
753
|
/** A zero-denominator result that names why it could not measure. */
|
|
609
754
|
function unreadable(path, reason) {
|
|
610
755
|
return { checked: 0, findings: [], skipped: [{ name: path, reason }] };
|
|
@@ -713,7 +858,7 @@ function scanAllBlocks(ctx, blocks, skipped) {
|
|
|
713
858
|
// real one, and #705 is explicit that a suppressed inference and a passing
|
|
714
859
|
// check must not look alike.
|
|
715
860
|
skipped.push({
|
|
716
|
-
name:
|
|
861
|
+
name: skipLabel(outOfBand ? 'VAC-003' : 'VAC-002/VAC-003', ctx.path, block),
|
|
717
862
|
reason: outOfBand
|
|
718
863
|
? 'target reached out of band (subprocess or bare dynamic import), so VAC-002 is answered but no symbol exists for absence-only to observe'
|
|
719
864
|
: 'target unresolvable: no @covers annotation and no first-party relative import to infer from',
|
|
@@ -305,6 +305,45 @@ export class WorkflowMapping {
|
|
|
305
305
|
export class WorkflowDiscoveryError extends Error {
|
|
306
306
|
}
|
|
307
307
|
// ---------------------------------------------------------------------------
|
|
308
|
+
// Jira request helpers
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Split out of `WorkflowDiscovery.fetchJira` to pay down its complexity. Kept
|
|
311
|
+
// at module level so the class keeps the Python port's method set, and each
|
|
312
|
+
// stays at or under the perf-ratchet warning threshold (10).
|
|
313
|
+
/** Read the ATLASSIAN_* env vars, refusing when any is unset or empty. */
|
|
314
|
+
function jiraCredentials() {
|
|
315
|
+
const baseUrl = rstripChar(process.env['ATLASSIAN_URL'] ?? '', '/');
|
|
316
|
+
const user = process.env['ATLASSIAN_USER'] ?? '';
|
|
317
|
+
const token = process.env['ATLASSIAN_TOKEN'] ?? '';
|
|
318
|
+
if (!pyTruthy(baseUrl) || !pyTruthy(user) || !pyTruthy(token)) {
|
|
319
|
+
throw new WorkflowDiscoveryError('Jira credentials not configured. Set ATLASSIAN_URL, ' +
|
|
320
|
+
'ATLASSIAN_USER, and ATLASSIAN_TOKEN environment variables.\n' +
|
|
321
|
+
'Tip: add them to .canary/company.local.json or your shell profile.');
|
|
322
|
+
}
|
|
323
|
+
const auth = Buffer.from(`${user}:${token}`).toString('base64');
|
|
324
|
+
return {
|
|
325
|
+
baseUrl,
|
|
326
|
+
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The issue-type list from the `issuetypes` response. An error object (Jira
|
|
331
|
+
* answers a missing project with `errorMessages` in a 200) is refused; any
|
|
332
|
+
* other non-list shape yields no issue types.
|
|
333
|
+
*/
|
|
334
|
+
function jiraIssueTypeList(issueTypesRaw, projectKey) {
|
|
335
|
+
if (Array.isArray(issueTypesRaw)) {
|
|
336
|
+
return issueTypesRaw;
|
|
337
|
+
}
|
|
338
|
+
if (typeof issueTypesRaw === 'object' &&
|
|
339
|
+
issueTypesRaw !== null &&
|
|
340
|
+
'errorMessages' in issueTypesRaw) {
|
|
341
|
+
throw new WorkflowDiscoveryError(`Jira project ${pyRepr(projectKey)} not found or access denied: ` +
|
|
342
|
+
`${pyRepr(issueTypesRaw['errorMessages'])}`);
|
|
343
|
+
}
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
308
347
|
// Main class
|
|
309
348
|
// ---------------------------------------------------------------------------
|
|
310
349
|
/** Discovers and caches per-project issue-workflow mappings. */
|
|
@@ -399,34 +438,13 @@ export class WorkflowDiscovery {
|
|
|
399
438
|
// -- private: Jira ---------------------------------------------------------
|
|
400
439
|
/** Python: `WorkflowDiscovery._fetch_jira`. */
|
|
401
440
|
async fetchJira(projectKey) {
|
|
402
|
-
const baseUrl =
|
|
403
|
-
const user = process.env['ATLASSIAN_USER'] ?? '';
|
|
404
|
-
const token = process.env['ATLASSIAN_TOKEN'] ?? '';
|
|
405
|
-
if (!pyTruthy(baseUrl) || !pyTruthy(user) || !pyTruthy(token)) {
|
|
406
|
-
throw new WorkflowDiscoveryError('Jira credentials not configured. Set ATLASSIAN_URL, ' +
|
|
407
|
-
'ATLASSIAN_USER, and ATLASSIAN_TOKEN environment variables.\n' +
|
|
408
|
-
'Tip: add them to .canary/company.local.json or your shell profile.');
|
|
409
|
-
}
|
|
410
|
-
const auth = Buffer.from(`${user}:${token}`).toString('base64');
|
|
411
|
-
const headers = {
|
|
412
|
-
Authorization: `Basic ${auth}`,
|
|
413
|
-
Accept: 'application/json',
|
|
414
|
-
};
|
|
441
|
+
const { baseUrl, headers } = jiraCredentials();
|
|
415
442
|
// Capture the URL so ticket_updater can use it without requiring the env var.
|
|
416
443
|
const discoveredBaseUrl = baseUrl;
|
|
417
444
|
// 1. Get issue types for this project.
|
|
418
445
|
const issueTypesRaw = await this.jiraGet(`${baseUrl}/rest/api/3/project/${projectKey}/issuetypes`, headers);
|
|
419
|
-
|
|
420
|
-
typeof issueTypesRaw === 'object' &&
|
|
421
|
-
issueTypesRaw !== null &&
|
|
422
|
-
'errorMessages' in issueTypesRaw) {
|
|
423
|
-
throw new WorkflowDiscoveryError(`Jira project ${pyRepr(projectKey)} not found or access denied: ` +
|
|
424
|
-
`${pyRepr(issueTypesRaw['errorMessages'])}`);
|
|
425
|
-
}
|
|
446
|
+
const list = jiraIssueTypeList(issueTypesRaw, projectKey);
|
|
426
447
|
const issueTypes = [];
|
|
427
|
-
const list = Array.isArray(issueTypesRaw)
|
|
428
|
-
? issueTypesRaw
|
|
429
|
-
: [];
|
|
430
448
|
for (const itRaw of list) {
|
|
431
449
|
const itId = String(pyGet(itRaw, 'id', ''));
|
|
432
450
|
const itName = String(pyGet(itRaw, 'name', ''));
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub evidence for derived adjudication (ADR 0025, #938).
|
|
3
|
+
*
|
|
4
|
+
* The network seam behind `adjudication.ts`: merged PRs in a window (GraphQL
|
|
5
|
+
* search), the guardian sticky's edit history (REST comments + GraphQL
|
|
6
|
+
* `userContentEdits`), and the merged file patches (REST). The sticky is found
|
|
7
|
+
* with the author-aware {@link findSticky} (#931), never by marker substring.
|
|
8
|
+
*
|
|
9
|
+
* Network lives ONLY in {@link GitHubAdjudicationSource}'s default seams;
|
|
10
|
+
* tests use {@link FakeAdjudicationSource} or inject `read` / `graphql`.
|
|
11
|
+
*/
|
|
12
|
+
import { readAllPages, restPageReader } from './github-paging.js';
|
|
13
|
+
import { findSticky } from './pr-comment.js';
|
|
14
|
+
const API = 'https://api.github.com';
|
|
15
|
+
/** The most merged PRs one report walks (bounds API cost and rate limits). */
|
|
16
|
+
const MERGED_PR_LIMIT = 300;
|
|
17
|
+
/** In-memory {@link AdjudicationSource} for tests. */
|
|
18
|
+
export class FakeAdjudicationSource {
|
|
19
|
+
init;
|
|
20
|
+
constructor(init) {
|
|
21
|
+
this.init = init;
|
|
22
|
+
}
|
|
23
|
+
async mergedPrs() {
|
|
24
|
+
return this.init.merged;
|
|
25
|
+
}
|
|
26
|
+
async stickyHistory(pr) {
|
|
27
|
+
const revisions = this.init.stickies?.[pr];
|
|
28
|
+
return revisions === undefined ? null : { revisions };
|
|
29
|
+
}
|
|
30
|
+
async prFiles(pr) {
|
|
31
|
+
return this.init.files?.[pr] ?? [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Sticky bodies oldest to newest. GitHub returns edits newest first and each
|
|
36
|
+
* `diff` holds the whole body at that revision. A truncated or redacted
|
|
37
|
+
* history is `null` (disclosed as "no edit history"), never a shorter list.
|
|
38
|
+
*/
|
|
39
|
+
export function revisionsFrom(body, edits) {
|
|
40
|
+
if (!edits)
|
|
41
|
+
return null;
|
|
42
|
+
if (edits.totalCount === 0)
|
|
43
|
+
return [body];
|
|
44
|
+
const diffs = edits.nodes.map((n) => n.diff);
|
|
45
|
+
if (diffs.length < edits.totalCount || diffs.includes(null))
|
|
46
|
+
return null;
|
|
47
|
+
return [...diffs.reverse(), body];
|
|
48
|
+
}
|
|
49
|
+
const SEARCH_QUERY = `query($q: String!, $after: String) {
|
|
50
|
+
search(query: $q, type: ISSUE, first: 100, after: $after) {
|
|
51
|
+
issueCount pageInfo { hasNextPage endCursor }
|
|
52
|
+
nodes { ... on PullRequest { number } } } }`;
|
|
53
|
+
const EDITS_QUERY = `query($id: ID!) { node(id: $id) { ... on IssueComment {
|
|
54
|
+
userContentEdits(first: 100) { totalCount nodes { diff } } } } }`;
|
|
55
|
+
function defaultGraphql(token) {
|
|
56
|
+
return async (query, variables) => {
|
|
57
|
+
const resp = await fetch(`${API}/graphql`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: `Bearer ${token}`,
|
|
61
|
+
'User-Agent': 'canary-pr-guardian',
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({ query, variables }),
|
|
64
|
+
});
|
|
65
|
+
const json = (await resp.json());
|
|
66
|
+
if (!resp.ok || json.errors || !json.data) {
|
|
67
|
+
throw new Error(`GitHub GraphQL ${resp.status}: ${JSON.stringify(json)}`);
|
|
68
|
+
}
|
|
69
|
+
return json.data;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** The real {@link AdjudicationSource} over GitHub REST + GraphQL. */
|
|
73
|
+
export class GitHubAdjudicationSource {
|
|
74
|
+
repo;
|
|
75
|
+
read;
|
|
76
|
+
graphql;
|
|
77
|
+
constructor(repo, token, seams = {}) {
|
|
78
|
+
this.repo = repo;
|
|
79
|
+
this.read =
|
|
80
|
+
seams.read ??
|
|
81
|
+
restPageReader({
|
|
82
|
+
Authorization: `Bearer ${token}`,
|
|
83
|
+
Accept: 'application/vnd.github+json',
|
|
84
|
+
'User-Agent': 'canary-pr-guardian',
|
|
85
|
+
}, (status, url) => new Error(`GitHub API ${status}: ${url}`));
|
|
86
|
+
this.graphql = seams.graphql ?? defaultGraphql(token);
|
|
87
|
+
}
|
|
88
|
+
/** Pages the search; refuses a window past {@link MERGED_PR_LIMIT} rather than truncating. */
|
|
89
|
+
async mergedPrs(since) {
|
|
90
|
+
const q = `repo:${this.repo} is:pr is:merged merged:>=${since}`;
|
|
91
|
+
const numbers = [];
|
|
92
|
+
let after = null;
|
|
93
|
+
do {
|
|
94
|
+
const data = await this.graphql(SEARCH_QUERY, { q, after });
|
|
95
|
+
const search = data['search'];
|
|
96
|
+
if (search.issueCount > MERGED_PR_LIMIT) {
|
|
97
|
+
throw new Error(`${search.issueCount} merged PRs since ${since} exceeds the ` +
|
|
98
|
+
`${MERGED_PR_LIMIT}-PR bound; narrow --days`);
|
|
99
|
+
}
|
|
100
|
+
numbers.push(...search.nodes.map((n) => n.number));
|
|
101
|
+
after = search.pageInfo?.hasNextPage ? search.pageInfo.endCursor : null;
|
|
102
|
+
} while (after !== null);
|
|
103
|
+
return numbers;
|
|
104
|
+
}
|
|
105
|
+
async stickyHistory(pr) {
|
|
106
|
+
const url = `${API}/repos/${this.repo}/issues/${pr}/comments`;
|
|
107
|
+
const comments = (await readAllPages(url, this.read));
|
|
108
|
+
const sticky = findSticky(comments);
|
|
109
|
+
if (sticky === null)
|
|
110
|
+
return null;
|
|
111
|
+
const data = await this.graphql(EDITS_QUERY, { id: sticky.node_id });
|
|
112
|
+
const node = data['node'];
|
|
113
|
+
return { revisions: revisionsFrom(sticky.body, node?.userContentEdits) };
|
|
114
|
+
}
|
|
115
|
+
async prFiles(pr) {
|
|
116
|
+
const url = `${API}/repos/${this.repo}/pulls/${pr}/files`;
|
|
117
|
+
return (await readAllPages(url, this.read));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Walk the merged PRs; PRs without a sticky are scanned but carry no evidence. */
|
|
121
|
+
export async function collectEvidence(source, since) {
|
|
122
|
+
const merged = await source.mergedPrs(since);
|
|
123
|
+
const evidence = [];
|
|
124
|
+
for (const number of merged) {
|
|
125
|
+
const history = await source.stickyHistory(number);
|
|
126
|
+
if (history === null)
|
|
127
|
+
continue;
|
|
128
|
+
evidence.push({
|
|
129
|
+
number,
|
|
130
|
+
revisions: history.revisions,
|
|
131
|
+
files: await source.prFiles(number),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return { evidence, scanned: merged.length };
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=adjudication-github.js.map
|