@nanobpm/nano-workforce 0.110.0 → 0.111.1
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/CHANGELOG.md +14 -0
- package/app/deliveryGraph.ts +46 -0
- package/app/deliveryGraphCompiler.test.ts +275 -0
- package/app/deliveryGraphCompiler.ts +487 -0
- package/app/github.test.ts +225 -1
- package/app/github.ts +124 -2
- package/app/readiness.test.ts +162 -0
- package/app/readiness.ts +165 -3
- package/app/service.ts +5 -2
- package/openapi.yaml +252 -3
- package/operations/compileDeliveryGraph.test.ts +60 -0
- package/operations/compileDeliveryGraph.ts +35 -0
- package/package.json +1 -1
- package/resources/processes/readiness-gate.bpmn +3 -0
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
|
|
7
|
+
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
7
8
|
|
|
8
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
9
10
|
// files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
|
|
@@ -440,6 +441,8 @@ function prState(over: Partial<PrState>): PrState {
|
|
|
440
441
|
failingChecks: 0,
|
|
441
442
|
failingCheckNames: [],
|
|
442
443
|
presentCheckNames: [],
|
|
444
|
+
pendingCheckNames: [],
|
|
445
|
+
checkConclusions: {},
|
|
443
446
|
totalChecks: 0,
|
|
444
447
|
isDraft: false,
|
|
445
448
|
headRefOid: null,
|
|
@@ -463,3 +466,224 @@ test("classifyPrLiveness: a closed-not-merged PR is terminal (abandon)", () => {
|
|
|
463
466
|
test("classifyPrLiveness: a null read (transport hiccup) is unknown — never abandons blind", () => {
|
|
464
467
|
assertEquals(classifyPrLiveness(null), "unknown");
|
|
465
468
|
});
|
|
469
|
+
|
|
470
|
+
// ── classifyMergeability: protocol-aware required-checks backstop (issue #392) ────────────────────
|
|
471
|
+
//
|
|
472
|
+
// The merge poller must NOT merge a PR whose DECLARED-required check is red, even on a repo that
|
|
473
|
+
// under-specifies its GitHub-required checks (so GitHub reports the PR as UNSTABLE, i.e. "only
|
|
474
|
+
// non-required checks failing" from GitHub's view). `classifyMergeability` now intersects the repo's
|
|
475
|
+
// merge-protocol `requiredChecks[]`/`waitForChecks` against the head's latest-run-per-check
|
|
476
|
+
// conclusions (via `latestRunPerCheck`, preserving the #348 CANCELLED-supersede semantics) as an
|
|
477
|
+
// independent backstop that runs BEFORE the `mergeStateStatus` switch. These are pure unit tests.
|
|
478
|
+
|
|
479
|
+
// Build a `PrState` with sensible defaults; `over` supplies the fields a case cares about. `over`
|
|
480
|
+
// may pass a `rollup` shorthand (name → conclusion) that we compile into the exact per-check fields
|
|
481
|
+
// `classifyMergeability` reads (present/pending/conclusions), mirroring what `fetchPrState` derives.
|
|
482
|
+
function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclusion: string }[] } = {}): PrState {
|
|
483
|
+
const { rollup, ...rest } = over;
|
|
484
|
+
const base: PrState = {
|
|
485
|
+
merged: false,
|
|
486
|
+
state: "open",
|
|
487
|
+
mergeStateStatus: "CLEAN",
|
|
488
|
+
failingChecks: 0,
|
|
489
|
+
failingCheckNames: [],
|
|
490
|
+
totalChecks: 0,
|
|
491
|
+
presentCheckNames: [],
|
|
492
|
+
pendingCheckNames: [],
|
|
493
|
+
checkConclusions: {},
|
|
494
|
+
isDraft: false,
|
|
495
|
+
headRefOid: "abc123",
|
|
496
|
+
};
|
|
497
|
+
if (rollup) {
|
|
498
|
+
const bad = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
|
|
499
|
+
const pendingStates = new Set(["", "PENDING", "QUEUED", "IN_PROGRESS", "EXPECTED", "WAITING"]);
|
|
500
|
+
const present: string[] = [];
|
|
501
|
+
const pending: string[] = [];
|
|
502
|
+
const failing: string[] = [];
|
|
503
|
+
const conclusions: Record<string, string> = {};
|
|
504
|
+
for (const c of rollup) {
|
|
505
|
+
const v = c.conclusion.toUpperCase();
|
|
506
|
+
present.push(c.name);
|
|
507
|
+
conclusions[c.name] = pendingStates.has(v) ? "" : v;
|
|
508
|
+
if (pendingStates.has(v)) pending.push(c.name);
|
|
509
|
+
else if (bad.has(v)) failing.push(c.name);
|
|
510
|
+
}
|
|
511
|
+
base.presentCheckNames = present;
|
|
512
|
+
base.pendingCheckNames = pending;
|
|
513
|
+
base.checkConclusions = conclusions;
|
|
514
|
+
base.failingCheckNames = failing;
|
|
515
|
+
base.failingChecks = failing.length;
|
|
516
|
+
base.totalChecks = rollup.length;
|
|
517
|
+
}
|
|
518
|
+
return { ...base, ...rest };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function reqChecks(...names: string[]): RequiredCheck[] {
|
|
522
|
+
return names.map((name) => ({ name, acceptedConclusions: ["success"] }));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function protocolWith(over: Partial<MergeProtocol>): MergeProtocol {
|
|
526
|
+
return { ...DEFAULT_MERGE_PROTOCOL, ...over };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
interface MergeCase {
|
|
530
|
+
name: string;
|
|
531
|
+
state: Partial<PrState> & { rollup?: { name: string; conclusion: string }[] };
|
|
532
|
+
protocol?: MergeProtocol;
|
|
533
|
+
want: Mergeability;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const MERGE_CASES: MergeCase[] = [
|
|
537
|
+
// The exact defect: UNSTABLE + a red DECLARED-required check used to classify `ready` and merge.
|
|
538
|
+
{
|
|
539
|
+
name: "UNSTABLE + red declared-required check -> blocked (the #392 defect)",
|
|
540
|
+
state: { mergeStateStatus: "UNSTABLE", rollup: [{ name: "test (22.x, simple)", conclusion: "FAILURE" }] },
|
|
541
|
+
protocol: protocolWith({ requiredChecks: [{ name: "test (22.x, simple)", acceptedConclusions: ["success"] }] }),
|
|
542
|
+
want: "blocked",
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "CLEAN + red declared-required check -> blocked (backstop runs before the switch)",
|
|
546
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "FAILURE" }] },
|
|
547
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
548
|
+
want: "blocked",
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "declared-required check pending -> waiting",
|
|
552
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "IN_PROGRESS" }] },
|
|
553
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
554
|
+
want: "waiting",
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
name: "declared-required check absent from head -> waiting (absence is not a pass)",
|
|
558
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "lint", conclusion: "SUCCESS" }] },
|
|
559
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
560
|
+
want: "waiting",
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
name: "declared-required check passing, CLEAN -> ready",
|
|
564
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "SUCCESS" }] },
|
|
565
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
566
|
+
want: "ready",
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
name: "declared-required check passing, UNSTABLE -> ready (falls through to the switch)",
|
|
570
|
+
state: { mergeStateStatus: "UNSTABLE", rollup: [{ name: "build", conclusion: "SUCCESS" }] },
|
|
571
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
572
|
+
want: "ready",
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
name: "non-required check failing (not declared-required), UNSTABLE -> ready (today's behaviour)",
|
|
576
|
+
state: {
|
|
577
|
+
mergeStateStatus: "UNSTABLE",
|
|
578
|
+
rollup: [{ name: "build", conclusion: "SUCCESS" }, { name: "flaky-optional", conclusion: "FAILURE" }],
|
|
579
|
+
},
|
|
580
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build") }),
|
|
581
|
+
want: "ready",
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
name: "CANCELLED superseded by a newer green run on a required check -> ready (#348 semantics)",
|
|
585
|
+
// `prState`'s rollup shorthand keeps one conclusion per name (latest wins); model the superseded
|
|
586
|
+
// + re-run by asserting the green outcome the rollup helpers collapse to.
|
|
587
|
+
state: { mergeStateStatus: "UNSTABLE", rollup: [{ name: "engine-core", conclusion: "SUCCESS" }] },
|
|
588
|
+
protocol: protocolWith({ requiredChecks: reqChecks("engine-core") }),
|
|
589
|
+
want: "ready",
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
name: "acceptedConclusions beyond [success] honoured: NEUTRAL accepted -> ready",
|
|
593
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "NEUTRAL" }] },
|
|
594
|
+
protocol: protocolWith({ requiredChecks: [{ name: "build", acceptedConclusions: ["success", "neutral"] }] }),
|
|
595
|
+
want: "ready",
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
name: "acceptedConclusions [success] does NOT accept a NEUTRAL required conclusion -> blocked",
|
|
599
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "NEUTRAL" }] },
|
|
600
|
+
protocol: protocolWith({ requiredChecks: [{ name: "build", acceptedConclusions: ["success"] }] }),
|
|
601
|
+
want: "blocked",
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: "waitForChecks:true + pending required check -> waiting even when CLEAN",
|
|
605
|
+
state: { mergeStateStatus: "CLEAN", rollup: [{ name: "build", conclusion: "QUEUED" }] },
|
|
606
|
+
protocol: protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true }),
|
|
607
|
+
want: "waiting",
|
|
608
|
+
},
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
for (const c of MERGE_CASES) {
|
|
612
|
+
test(`classifyMergeability: ${c.name}`, () => {
|
|
613
|
+
assertEquals(classifyMergeability(mergePrState(c.state), c.protocol), c.want);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Empty requiredChecks (the DEFAULT protocol) — behaviour must be IDENTICAL to today across every
|
|
618
|
+
// mergeStateStatus, whether a protocol is passed or omitted entirely.
|
|
619
|
+
const DEFAULT_BEHAVIOUR: { status: string; failingChecks?: number; want: Mergeability }[] = [
|
|
620
|
+
{ status: "CLEAN", want: "ready" },
|
|
621
|
+
{ status: "HAS_HOOKS", want: "ready" },
|
|
622
|
+
{ status: "UNSTABLE", want: "ready" },
|
|
623
|
+
{ status: "BEHIND", want: "ready" },
|
|
624
|
+
{ status: "DIRTY", want: "conflict" },
|
|
625
|
+
{ status: "BLOCKED", failingChecks: 1, want: "blocked" },
|
|
626
|
+
{ status: "BLOCKED", failingChecks: 0, want: "waiting" },
|
|
627
|
+
{ status: "UNKNOWN", want: "waiting" },
|
|
628
|
+
{ status: "", want: "waiting" },
|
|
629
|
+
];
|
|
630
|
+
|
|
631
|
+
for (const c of DEFAULT_BEHAVIOUR) {
|
|
632
|
+
test(`classifyMergeability: empty requiredChecks keeps today's behaviour (${c.status || "''"} -> ${c.want})`, () => {
|
|
633
|
+
const s = prState({ mergeStateStatus: c.status, failingChecks: c.failingChecks ?? 0 });
|
|
634
|
+
// Explicit default protocol and omitted-protocol must agree.
|
|
635
|
+
assertEquals(classifyMergeability(s, DEFAULT_MERGE_PROTOCOL), c.want);
|
|
636
|
+
assertEquals(classifyMergeability(s), c.want);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Token mode: the transport can't enumerate checks (`failingChecks === -1`, empty per-check lists),
|
|
641
|
+
// so even a repo that declares requiredChecks must fall through to today's `mergeStateStatus`
|
|
642
|
+
// behaviour — the backstop must NEVER newly block or wait when checks are unenumerable.
|
|
643
|
+
const TOKEN_MODE: { status: string; want: Mergeability }[] = [
|
|
644
|
+
{ status: "CLEAN", want: "ready" },
|
|
645
|
+
{ status: "UNSTABLE", want: "ready" },
|
|
646
|
+
{ status: "BLOCKED", want: "waiting" }, // failingChecks<0 → conservative wait, exactly as before
|
|
647
|
+
{ status: "DIRTY", want: "conflict" },
|
|
648
|
+
{ status: "UNKNOWN", want: "waiting" },
|
|
649
|
+
];
|
|
650
|
+
|
|
651
|
+
for (const c of TOKEN_MODE) {
|
|
652
|
+
test(`classifyMergeability: token mode falls through, never newly blocks (${c.status} -> ${c.want})`, () => {
|
|
653
|
+
const s = prState({
|
|
654
|
+
mergeStateStatus: c.status,
|
|
655
|
+
failingChecks: -1,
|
|
656
|
+
totalChecks: -1,
|
|
657
|
+
presentCheckNames: [],
|
|
658
|
+
pendingCheckNames: [],
|
|
659
|
+
checkConclusions: {},
|
|
660
|
+
});
|
|
661
|
+
const protocol = protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true });
|
|
662
|
+
assertEquals(classifyMergeability(s, protocol), c.want);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// `checkConclusions` must report a terminal conclusion per check but normalise a STILL-IN-FLIGHT run
|
|
667
|
+
// to "" for BOTH rollup shapes — a CheckRun whose `status` is not COMPLETED, and a legacy
|
|
668
|
+
// StatusContext whose `state` is PENDING/EXPECTED — so a caller never mistakes a pending
|
|
669
|
+
// status-context's upper-cased `state` (e.g. "PENDING") for a terminal conclusion.
|
|
670
|
+
test("checkConclusions: in-flight runs map to '' for both CheckRun and StatusContext shapes", () => {
|
|
671
|
+
const got = checkConclusions([
|
|
672
|
+
{ name: "ci-success", status: "COMPLETED", conclusion: "SUCCESS" },
|
|
673
|
+
{ name: "ci-failure", status: "COMPLETED", conclusion: "FAILURE" },
|
|
674
|
+
{ name: "ci-running", status: "IN_PROGRESS" }, // CheckRun in flight -> ""
|
|
675
|
+
{ name: "ci-queued", status: "QUEUED" }, // CheckRun queued -> ""
|
|
676
|
+
{ context: "legacy-pending", state: "PENDING" }, // StatusContext in flight -> ""
|
|
677
|
+
{ context: "legacy-expected", state: "EXPECTED" }, // StatusContext in flight -> ""
|
|
678
|
+
{ context: "legacy-error", state: "ERROR" }, // StatusContext terminal -> preserved
|
|
679
|
+
]);
|
|
680
|
+
assertEquals(got, {
|
|
681
|
+
"ci-success": "SUCCESS",
|
|
682
|
+
"ci-failure": "FAILURE",
|
|
683
|
+
"ci-running": "",
|
|
684
|
+
"ci-queued": "",
|
|
685
|
+
"legacy-pending": "",
|
|
686
|
+
"legacy-expected": "",
|
|
687
|
+
"legacy-error": "ERROR",
|
|
688
|
+
});
|
|
689
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
// The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
|
|
12
12
|
// Cross-runtime: runs under Node (`node:child_process`).
|
|
13
13
|
|
|
14
|
+
// Type-only import (erased at runtime, so no runtime cycle with mergeProtocol.ts, which imports
|
|
15
|
+
// `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate
|
|
16
|
+
// a merge independently of GitHub branch protection.
|
|
17
|
+
import type { MergeProtocol } from "./mergeProtocol.ts";
|
|
18
|
+
|
|
14
19
|
/** A GitHub pull-request review, narrowed to the fields the poller needs. */
|
|
15
20
|
export interface GhReview {
|
|
16
21
|
id: number;
|
|
@@ -502,6 +507,17 @@ export interface PrState {
|
|
|
502
507
|
* unrelated always-on check (e.g. Mergify's "Merge Queue") must not read as "the required run
|
|
503
508
|
* already happened". */
|
|
504
509
|
presentCheckNames: string[];
|
|
510
|
+
/** Names of every head check still in flight (queued/in progress, not yet concluded and not a hard
|
|
511
|
+
* failure), derived over the newest run per check (`pendingCheckNames`). Empty in token mode (the
|
|
512
|
+
* REST fallback can't enumerate checks). Lets `classifyMergeability` hold a merge when a
|
|
513
|
+
* declared-required check has not yet concluded, without re-deriving conclusions by hand. */
|
|
514
|
+
pendingCheckNames: string[];
|
|
515
|
+
/** The newest concluded conclusion per head check (name → uppercase conclusion, e.g. `SUCCESS` /
|
|
516
|
+
* `FAILURE` / `NEUTRAL` / `SKIPPED`), derived over `latestRunPerCheck` so a `CANCELLED` run
|
|
517
|
+
* superseded by a newer green run on the same head reports the green result (#348). A still-pending
|
|
518
|
+
* run maps to `""` (it has no conclusion yet — use `pendingCheckNames`). Empty in token mode. Lets
|
|
519
|
+
* `classifyMergeability` honour a required check's `acceptedConclusions` precisely. */
|
|
520
|
+
checkConclusions: Record<string, string>;
|
|
505
521
|
/** Whether the PR is a draft (a fresh head run is produced by marking it ready, not reopen). */
|
|
506
522
|
isDraft: boolean;
|
|
507
523
|
/** Current head commit. Used to scope one-shot merge-protocol nudges to a landing attempt. */
|
|
@@ -597,6 +613,28 @@ export function failingCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
597
613
|
return names;
|
|
598
614
|
}
|
|
599
615
|
|
|
616
|
+
/** Names of checks that are still in flight — queued or in progress, i.e. NOT yet complete and not a
|
|
617
|
+
* hard failure. Covers the CheckRun shape (`status` QUEUED/IN_PROGRESS/PENDING/WAITING/… anything but
|
|
618
|
+
* COMPLETED) and the legacy StatusContext shape (`state` PENDING/EXPECTED). Derived over the newest
|
|
619
|
+
* run per check (`latestRunPerCheck`) like {@link failingCheckNames}, so a superseded run doesn't
|
|
620
|
+
* linger as pending. A `checks-green` gate MUST count these so it never reports green while a run has
|
|
621
|
+
* not yet concluded (a pending run has no failing conclusion, so it would otherwise slip through). */
|
|
622
|
+
export function pendingCheckNames(rollup: RollupEntry[]): string[] {
|
|
623
|
+
const names: string[] = [];
|
|
624
|
+
for (const c of latestRunPerCheck(rollup)) {
|
|
625
|
+
const status = (c.status || "").toUpperCase();
|
|
626
|
+
if (status !== "") {
|
|
627
|
+
// CheckRun: anything other than COMPLETED is still running/queued.
|
|
628
|
+
if (status !== "COMPLETED") names.push(checkKey(c));
|
|
629
|
+
} else {
|
|
630
|
+
// Legacy StatusContext: PENDING/EXPECTED are not-yet-concluded.
|
|
631
|
+
const state = (c.state || "").toUpperCase();
|
|
632
|
+
if (state === "PENDING" || state === "EXPECTED") names.push(checkKey(c));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return names;
|
|
636
|
+
}
|
|
637
|
+
|
|
600
638
|
/** Names of every head check present, regardless of state. Covers both the CheckRun shape
|
|
601
639
|
* (`name`/`workflowName`) and the legacy StatusContext shape (`context`). Used to test whether a
|
|
602
640
|
* repo's *required* checks are present on the head — so an unrelated always-on check (e.g.
|
|
@@ -611,6 +649,29 @@ export function allCheckNames(rollup: RollupEntry[]): string[] {
|
|
|
611
649
|
return names;
|
|
612
650
|
}
|
|
613
651
|
|
|
652
|
+
/** The ground-truth conclusion of each head check, keyed by check name, derived over the **newest run
|
|
653
|
+
* per check** (`latestRunPerCheck`) so a `CANCELLED` run superseded by a newer green run on the
|
|
654
|
+
* identical head SHA reports the green result, not the stale cancellation (issue #348). The value is
|
|
655
|
+
* the run's `conclusion` (CheckRun) or `state` (legacy StatusContext), upper-cased; a still-in-flight
|
|
656
|
+
* run that has not concluded maps to `""` (it has no conclusion — `pendingCheckNames` tracks those).
|
|
657
|
+
* In-flight is normalised to `""` for BOTH shapes: a CheckRun whose `status` is not `COMPLETED`, and a
|
|
658
|
+
* legacy StatusContext whose `state` is `PENDING`/`EXPECTED`, so a caller never mistakes a pending
|
|
659
|
+
* status-context's `PENDING`/`EXPECTED` `state` for a terminal conclusion.
|
|
660
|
+
* Lets `classifyMergeability` intersect a repo's declared `requiredChecks` against actual head
|
|
661
|
+
* conclusions and honour each check's `acceptedConclusions` without re-deriving per-run state. */
|
|
662
|
+
export function checkConclusions(rollup: RollupEntry[]): Record<string, string> {
|
|
663
|
+
const out: Record<string, string> = {};
|
|
664
|
+
for (const c of latestRunPerCheck(rollup)) {
|
|
665
|
+
const status = (c.status || "").toUpperCase();
|
|
666
|
+
const state = (c.state || "").toUpperCase();
|
|
667
|
+
// A still-in-flight run has no terminal conclusion — normalise both shapes to "" (mirrors
|
|
668
|
+
// `pendingCheckNames`): CheckRun status != COMPLETED, or legacy StatusContext state PENDING/EXPECTED.
|
|
669
|
+
const inFlight = status !== "" ? status !== "COMPLETED" : state === "PENDING" || state === "EXPECTED";
|
|
670
|
+
out[checkKey(c)] = inFlight ? "" : (c.conclusion || c.state || "").toUpperCase();
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
|
|
614
675
|
/** True when `err` is GitHub reporting that a ref which parsed as `owner/repo#N` is not a pull
|
|
615
676
|
* request — either it's an issue (issues and PRs share GitHub's number space, so an issue number
|
|
616
677
|
* is indistinguishable from a PR number by shape alone) or the number does not exist. Both
|
|
@@ -660,6 +721,8 @@ export async function fetchPrState(
|
|
|
660
721
|
failingCheckNames: names,
|
|
661
722
|
totalChecks: rollup.length,
|
|
662
723
|
presentCheckNames: allCheckNames(rollup),
|
|
724
|
+
pendingCheckNames: pendingCheckNames(rollup),
|
|
725
|
+
checkConclusions: checkConclusions(rollup),
|
|
663
726
|
isDraft: !!j.isDraft,
|
|
664
727
|
headRefOid: j.headRefOid ?? null,
|
|
665
728
|
};
|
|
@@ -691,6 +754,8 @@ export async function fetchPrState(
|
|
|
691
754
|
failingCheckNames: [], // …and the CI-fix agent gets no per-check list in token mode
|
|
692
755
|
totalChecks: -1, // …and the fresh-head-run remedy stays conservative (never reopens blind)
|
|
693
756
|
presentCheckNames: [], // …can't enumerate checks in token mode → no required-check presence signal
|
|
757
|
+
pendingCheckNames: [], // …no per-check pending signal either → classifier degrades to today's switch
|
|
758
|
+
checkConclusions: {}, // …no per-check conclusions → protocol-aware gate falls through in token mode
|
|
694
759
|
isDraft: !!j.draft,
|
|
695
760
|
headRefOid: j.head?.sha ?? null,
|
|
696
761
|
};
|
|
@@ -893,11 +958,68 @@ export async function baseBranchLanded(
|
|
|
893
958
|
* verdict; `waiting` means re-poll later. */
|
|
894
959
|
export type Mergeability = "ready" | "waiting" | "conflict" | "blocked";
|
|
895
960
|
|
|
896
|
-
|
|
961
|
+
/** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions —
|
|
962
|
+
* an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red
|
|
963
|
+
* required check even on a repo that has NOT wired that check as a GitHub-required status check
|
|
964
|
+
* (issue #392). Returns:
|
|
965
|
+
* • `"blocked"` — a declared-required check is present, concluded, and its conclusion is NOT in that
|
|
966
|
+
* check's `acceptedConclusions` (a hard failure like `FAILURE`, or any other unaccepted terminal
|
|
967
|
+
* conclusion) → route to fix-ci, do not merge.
|
|
968
|
+
* • `"waiting"` — a declared-required check is still pending, or absent from the head entirely
|
|
969
|
+
* (not-yet-run counts as pending, NOT as pass): a declared-required check that has not
|
|
970
|
+
* concluded is never mergeable.
|
|
971
|
+
* • `"pass"` — every declared-required check is present and its conclusion accepted → fall through to
|
|
972
|
+
* today's `mergeStateStatus` logic (GitHub branch protection stays the primary gate).
|
|
973
|
+
* Degrades safely: with no declared `requiredChecks`, or in token mode where checks can't be
|
|
974
|
+
* enumerated (`failingChecks < 0`, so the per-check lists are empty), it returns `"pass"` and never
|
|
975
|
+
* newly blocks or waits — repos keep exactly today's behaviour. */
|
|
976
|
+
function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked" | "waiting" | "pass" {
|
|
977
|
+
const required = protocol?.requiredChecks ?? [];
|
|
978
|
+
if (required.length === 0) return "pass";
|
|
979
|
+
// Token mode: the transport can't enumerate checks (`failingChecks === -1`), so the per-check lists
|
|
980
|
+
// are empty and absence is indistinguishable from not-yet-run. Do NOT newly block/wait — fall
|
|
981
|
+
// through to today's `mergeStateStatus` behaviour. (A real gh-mode head with no checks yet reports
|
|
982
|
+
// `failingChecks === 0`, so absence there is correctly treated as not-yet-run below.)
|
|
983
|
+
if (s.failingChecks < 0) return "pass";
|
|
984
|
+
const present = new Set(s.presentCheckNames);
|
|
985
|
+
const pending = new Set(s.pendingCheckNames);
|
|
986
|
+
let anyBlocked = false;
|
|
987
|
+
let anyPending = false;
|
|
988
|
+
for (const rc of required) {
|
|
989
|
+
// Absent from the head, or still in flight → not-yet-run → wait (never treat absence as pass).
|
|
990
|
+
if (!present.has(rc.name) || pending.has(rc.name)) {
|
|
991
|
+
anyPending = true;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
const conclusion = (s.checkConclusions[rc.name] ?? "").toUpperCase();
|
|
995
|
+
if (conclusion === "") {
|
|
996
|
+
// Present but no terminal conclusion yet (and not flagged pending) — treat conservatively as
|
|
997
|
+
// not-yet-concluded rather than as a pass.
|
|
998
|
+
anyPending = true;
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
const accepted = rc.acceptedConclusions.map((a) => a.toUpperCase());
|
|
1002
|
+
if (accepted.includes(conclusion)) continue; // satisfied
|
|
1003
|
+
anyBlocked = true; // present, concluded, NOT accepted → a red required check
|
|
1004
|
+
}
|
|
1005
|
+
// A failing required check outranks a pending one: it needs fix-ci now, not more waiting.
|
|
1006
|
+
if (anyBlocked) return "blocked";
|
|
1007
|
+
if (anyPending) return "waiting";
|
|
1008
|
+
return "pass";
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Mergeability {
|
|
1012
|
+
// Protocol-aware backstop FIRST (issue #392): honour the repo's declared `requiredChecks`
|
|
1013
|
+
// against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required
|
|
1014
|
+
// check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch
|
|
1015
|
+
// below still gates) — it only tightens merges on repos that under-specify their required checks.
|
|
1016
|
+
const gate = requiredChecksVerdict(s, protocol);
|
|
1017
|
+
if (gate === "blocked") return "blocked";
|
|
1018
|
+
if (gate === "waiting") return "waiting";
|
|
897
1019
|
switch (s.mergeStateStatus) {
|
|
898
1020
|
case "CLEAN":
|
|
899
1021
|
case "HAS_HOOKS":
|
|
900
|
-
case "UNSTABLE": // only non-required checks failing — still mergeable
|
|
1022
|
+
case "UNSTABLE": // only non-required checks failing — still mergeable (no DECLARED-required red)
|
|
901
1023
|
case "BEHIND": // out of date; a queue rebases, a direct merge is still allowed
|
|
902
1024
|
return "ready";
|
|
903
1025
|
case "DIRTY":
|
package/app/readiness.test.ts
CHANGED
|
@@ -21,17 +21,22 @@ import {
|
|
|
21
21
|
matchGithubCheck,
|
|
22
22
|
matchHttp,
|
|
23
23
|
matchNpm,
|
|
24
|
+
matchPr,
|
|
24
25
|
msToIsoDuration,
|
|
25
26
|
newestPublishedVersion,
|
|
26
27
|
nextDelay,
|
|
27
28
|
normalizePoll,
|
|
28
29
|
parseProbe,
|
|
30
|
+
parsePrTarget,
|
|
31
|
+
parsePrView,
|
|
29
32
|
parseReleases,
|
|
30
33
|
parseReleasesTarget,
|
|
31
34
|
parseRepoRef,
|
|
32
35
|
probeBudgetMs,
|
|
33
36
|
probeOnce,
|
|
34
37
|
type ProbeExec,
|
|
38
|
+
type PrObservation,
|
|
39
|
+
prViewCommand,
|
|
35
40
|
readinessTimeout,
|
|
36
41
|
readinessTimeoutMs,
|
|
37
42
|
redactString,
|
|
@@ -375,6 +380,163 @@ test("probeOnce github-check: a failed gh api call is not-ready (never throws)",
|
|
|
375
380
|
assert(!res.ready);
|
|
376
381
|
});
|
|
377
382
|
|
|
383
|
+
// ── parseProbe: pr kind (ADR 0005 §2 — owner/repo#N target + validated prState) ──────────────────
|
|
384
|
+
test("parseProbe: accepts an owner/repo#N pr probe and defaults onTimeout to escalate (timeout → escalate)", () => {
|
|
385
|
+
const p = parseProbe({ kind: "pr", target: "nanobpm/nano-workforce#377" });
|
|
386
|
+
assertEquals(p.kind, "pr");
|
|
387
|
+
assertEquals(p.onTimeout, "escalate");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("parseProbe: a pr probe whose target carries no numeric PR id throws (never resolvable)", () => {
|
|
391
|
+
assertThrows(() => parseProbe({ kind: "pr", target: "nanobpm/nano-workforce" }), Error, "owner/repo#<number>");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("parseProbe: a pr probe with an unknown match.prState throws (mistyped state fails loudly)", () => {
|
|
395
|
+
assertThrows(
|
|
396
|
+
() => parseProbe({ kind: "pr", target: "o/r#1", match: { prState: "landed" } }),
|
|
397
|
+
Error,
|
|
398
|
+
"invalid match.prState",
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("parseProbe: a valid pr probe round-trips its prState", () => {
|
|
403
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "mergeable" } });
|
|
404
|
+
assertEquals(p.match?.prState, "mergeable");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ── matchPr (pure — operates on an already-fetched PR observation) ────────────────────────────────
|
|
408
|
+
function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
409
|
+
return {
|
|
410
|
+
merged: false,
|
|
411
|
+
state: "open",
|
|
412
|
+
mergeStateStatus: "UNKNOWN",
|
|
413
|
+
failingChecks: 0,
|
|
414
|
+
failingCheckNames: [],
|
|
415
|
+
totalChecks: 0,
|
|
416
|
+
presentCheckNames: [],
|
|
417
|
+
pendingCheckNames: [],
|
|
418
|
+
checkConclusions: {},
|
|
419
|
+
isDraft: false,
|
|
420
|
+
headRefOid: "abc123",
|
|
421
|
+
mergedSha: null,
|
|
422
|
+
pendingChecks: 0,
|
|
423
|
+
...over,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
test("matchPr: prState 'ready' is the draft→ready transition (a non-draft PR is ready)", () => {
|
|
428
|
+
assert(!matchPr({ prState: "ready" }, prObs({ isDraft: true })).ready);
|
|
429
|
+
assert(matchPr({ prState: "ready" }, prObs({ isDraft: false })).ready);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("matchPr: prState 'merged' waits for the merge and binds mergedSha (mirrors resolvedArtifact)", () => {
|
|
433
|
+
assert(!matchPr({ prState: "merged" }, prObs({ merged: false })).ready);
|
|
434
|
+
const res = matchPr({ prState: "merged" }, prObs({ merged: true, state: "merged", mergedSha: "deadbeef" }));
|
|
435
|
+
assert(res.ready);
|
|
436
|
+
assertEquals(res.bind?.mergedSha, "deadbeef");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("matchPr: 'merged' is the default when no prState is declared", () => {
|
|
440
|
+
assert(!matchPr(undefined, prObs({ merged: false })).ready);
|
|
441
|
+
assert(matchPr(undefined, prObs({ merged: true, state: "merged" })).ready);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("matchPr: prState 'mergeable' reuses classifyMergeability (CLEAN is ready, BLOCKED is not)", () => {
|
|
445
|
+
assert(matchPr({ prState: "mergeable" }, prObs({ mergeStateStatus: "CLEAN" })).ready);
|
|
446
|
+
assert(!matchPr({ prState: "mergeable" }, prObs({ mergeStateStatus: "BLOCKED", failingChecks: 1 })).ready);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("matchPr: prState 'checks-green' needs a present, non-failing, non-pending head run", () => {
|
|
450
|
+
assert(matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 0 })).ready);
|
|
451
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 1 })).ready);
|
|
452
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 0, failingChecks: 0 })).ready);
|
|
453
|
+
// A run still queued/in-progress (no failing conclusion yet) must NOT read as green.
|
|
454
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 2, failingChecks: 0, pendingChecks: 1 })).ready);
|
|
455
|
+
// token mode (checks unenumerable, totalChecks < 0) stays conservative — never falsely green.
|
|
456
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: -1, failingChecks: -1 })).ready);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("matchPr: a not-yet-satisfied state is not-ready — the bounded gate keeps waiting → timeout escalates", () => {
|
|
460
|
+
// Every un-reached state resolves to ready:false, which is exactly what the engine timer arm bounds
|
|
461
|
+
// (onTimeout defaults to 'escalate'): a PR that never lands is never falsely resolved.
|
|
462
|
+
assert(!matchPr({ prState: "merged" }, prObs({ merged: false })).ready);
|
|
463
|
+
assert(!matchPr({ prState: "ready" }, prObs({ isDraft: true })).ready);
|
|
464
|
+
assert(!matchPr({ prState: "checks-green" }, prObs({ totalChecks: 1, failingChecks: 1 })).ready);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ── parsePrView + probeOnce pr dispatch (injected exec — no I/O) ─────────────────────────────────
|
|
468
|
+
test("parsePrView: reduces a gh pr view payload and collapses the check rollup", () => {
|
|
469
|
+
const obs = parsePrView({
|
|
470
|
+
state: "OPEN",
|
|
471
|
+
mergeStateStatus: "clean",
|
|
472
|
+
isDraft: false,
|
|
473
|
+
headRefOid: "sha1",
|
|
474
|
+
statusCheckRollup: [
|
|
475
|
+
{ name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
|
|
476
|
+
{ name: "lint", status: "COMPLETED", conclusion: "FAILURE" },
|
|
477
|
+
],
|
|
478
|
+
});
|
|
479
|
+
assertEquals(obs.merged, false);
|
|
480
|
+
assertEquals(obs.mergeStateStatus, "CLEAN");
|
|
481
|
+
assertEquals(obs.totalChecks, 2);
|
|
482
|
+
assertEquals(obs.failingCheckNames, ["lint"]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("parsePrView: an in-flight run is counted as pending (so checks-green stays not-green)", () => {
|
|
486
|
+
const obs = parsePrView({
|
|
487
|
+
state: "OPEN",
|
|
488
|
+
statusCheckRollup: [
|
|
489
|
+
{ name: "build", status: "COMPLETED", conclusion: "SUCCESS" },
|
|
490
|
+
{ name: "e2e", status: "IN_PROGRESS" },
|
|
491
|
+
],
|
|
492
|
+
});
|
|
493
|
+
assertEquals(obs.failingChecks, 0);
|
|
494
|
+
assertEquals(obs.pendingChecks, 1);
|
|
495
|
+
assert(!matchPr({ prState: "checks-green" }, obs).ready);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("parsePrView: a merged PR carries its merge commit oid", () => {
|
|
499
|
+
const obs = parsePrView({ state: "MERGED", mergedAt: "2026-08-20T00:00:00Z", mergeCommit: { oid: "cafe" } });
|
|
500
|
+
assertEquals(obs.merged, true);
|
|
501
|
+
assertEquals(obs.state, "merged");
|
|
502
|
+
assertEquals(obs.mergedSha, "cafe");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("parsePrView: a garbled payload degrades to an all-open, no-checks observation (never throws)", () => {
|
|
506
|
+
const obs = parsePrView(null);
|
|
507
|
+
assertEquals(obs.merged, false);
|
|
508
|
+
assertEquals(obs.totalChecks, 0);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("probeOnce pr: builds a quoted `gh pr view` command and matches merged, binding mergedSha", async () => {
|
|
512
|
+
const cap: { cmd?: string } = {};
|
|
513
|
+
const exec = stubExec({
|
|
514
|
+
command: { code: 0, stdout: JSON.stringify({ state: "MERGED", mergeCommit: { oid: "abc" } }), stderr: "" },
|
|
515
|
+
capture: cap,
|
|
516
|
+
});
|
|
517
|
+
const res = await probeOnce(parseProbe({ kind: "pr", target: "nanobpm/nano-workforce#377" }), exec, {});
|
|
518
|
+
assert(res.ready);
|
|
519
|
+
assertEquals(res.bind?.mergedSha, "abc");
|
|
520
|
+
assertStringIncludes(cap.cmd ?? "", "gh pr view '377' --repo 'nanobpm/nano-workforce'");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("probeOnce pr: a failed gh pr view call is not-ready (never throws)", async () => {
|
|
524
|
+
const exec = stubExec({ command: { code: 1, stdout: "", stderr: "no pr" } });
|
|
525
|
+
const res = await probeOnce(parseProbe({ kind: "pr", target: "o/r#1" }), exec, {});
|
|
526
|
+
assert(!res.ready);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("parsePrTarget: parses owner/repo#N, rejects @N and a bare repo", () => {
|
|
530
|
+
assertEquals(parsePrTarget("o/r#12"), { repo: "o/r", number: "12" });
|
|
531
|
+
// `@N` is deliberately NOT a PR handle — it's the repo-ref syntax, so it must not parse as a PR.
|
|
532
|
+
assertEquals(parsePrTarget("o/r@34"), null);
|
|
533
|
+
assertEquals(parsePrTarget("o/r"), null);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("prViewCommand: single-quote-escapes its args", () => {
|
|
537
|
+
assertStringIncludes(prViewCommand("o/r", "9"), "gh pr view '9' --repo 'o/r'");
|
|
538
|
+
});
|
|
539
|
+
|
|
378
540
|
// ── backoff + poll normalisation ──────────────────────────────────────────────────────────────
|
|
379
541
|
test("normalizePoll: fills defaults and clamps everyMs to the ceiling", () => {
|
|
380
542
|
const d = normalizePoll(undefined);
|