@nanobpm/nano-workforce 0.210.0 → 0.210.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 +6 -0
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/workers/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
package/app/readiness.test.ts
CHANGED
|
@@ -554,6 +554,95 @@ test("#856 parsePrTarget agrees with the canonical parsePr on every PR shape (no
|
|
|
554
554
|
}
|
|
555
555
|
});
|
|
556
556
|
|
|
557
|
+
// ── PR #957 review round 3: the readiness `pr` path normalises events too ────────────────────────
|
|
558
|
+
//
|
|
559
|
+
// The `gh pr view` rollup omits `event`, so before this fix the readiness path grouped by name alone
|
|
560
|
+
// and a green `push` run hid a red required `pull_request` run of the same name — `checks-green`
|
|
561
|
+
// reported ready while the PR run was red. `probeOnce` now enriches the rollup through the injected
|
|
562
|
+
// executor (shared transport logic with the merge loop) before parsing.
|
|
563
|
+
|
|
564
|
+
test("#957 probeOnce pr checks-green: a green push run never hides a red pull_request run of the same name", async () => {
|
|
565
|
+
const head = "5fbd262a28987d647621da324bf1a7855a8da500";
|
|
566
|
+
// The `gh pr view` payload: two same-named runs, NO event on either (the CLI exporter omits it).
|
|
567
|
+
const prView = {
|
|
568
|
+
state: "OPEN",
|
|
569
|
+
mergedAt: null,
|
|
570
|
+
mergeStateStatus: "CLEAN",
|
|
571
|
+
isDraft: false,
|
|
572
|
+
headRefOid: head,
|
|
573
|
+
mergeCommit: null,
|
|
574
|
+
statusCheckRollup: [
|
|
575
|
+
{ name: "build", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
|
|
576
|
+
{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
// The GraphQL event read, keyed by the SAME head, joining by (name, startedAt).
|
|
580
|
+
const events = {
|
|
581
|
+
data: {
|
|
582
|
+
repository: {
|
|
583
|
+
pullRequest: {
|
|
584
|
+
commits: {
|
|
585
|
+
nodes: [
|
|
586
|
+
{
|
|
587
|
+
commit: {
|
|
588
|
+
oid: head,
|
|
589
|
+
statusCheckRollup: {
|
|
590
|
+
contexts: {
|
|
591
|
+
nodes: [
|
|
592
|
+
{ __typename: "CheckRun", databaseId: 1001, name: "build", startedAt: "2026-11-12T00:30:00Z", checkSuite: { workflowRun: { event: "pull_request" } } },
|
|
593
|
+
{ __typename: "CheckRun", databaseId: 1002, name: "build", startedAt: "2026-11-12T00:41:30Z", checkSuite: { workflowRun: { event: "push" } } },
|
|
594
|
+
],
|
|
595
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
const exec: ProbeExec = {
|
|
607
|
+
async httpGet() {
|
|
608
|
+
return { status: 0, body: "" };
|
|
609
|
+
},
|
|
610
|
+
async run(command) {
|
|
611
|
+
// Route the two reads by shape: the GraphQL call carries `api graphql`, the rollup read `pr view`.
|
|
612
|
+
if (command.includes("api") && command.includes("graphql")) return { code: 0, stdout: JSON.stringify(events), stderr: "" };
|
|
613
|
+
return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
|
|
617
|
+
const res = await probeOnce(p, exec, {});
|
|
618
|
+
assertEquals(res.ready, false, "the red pull_request run survives event normalisation — checks-green is NOT ready");
|
|
619
|
+
assertStringIncludes(res.detail, "failing");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("#957 probeOnce pr checks-green: an event-enrichment failure degrades to name-only, never throws", async () => {
|
|
623
|
+
const prView = {
|
|
624
|
+
state: "OPEN",
|
|
625
|
+
mergedAt: null,
|
|
626
|
+
mergeStateStatus: "CLEAN",
|
|
627
|
+
isDraft: false,
|
|
628
|
+
headRefOid: "abc",
|
|
629
|
+
mergeCommit: null,
|
|
630
|
+
statusCheckRollup: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" }],
|
|
631
|
+
};
|
|
632
|
+
const exec: ProbeExec = {
|
|
633
|
+
async httpGet() {
|
|
634
|
+
return { status: 0, body: "" };
|
|
635
|
+
},
|
|
636
|
+
async run(command) {
|
|
637
|
+
if (command.includes("api") && command.includes("graphql")) return { code: 1, stdout: "", stderr: "boom" }; // the event read fails
|
|
638
|
+
return { code: 0, stdout: JSON.stringify(prView), stderr: "" };
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
const p = parseProbe({ kind: "pr", target: "o/r#12", match: { prState: "checks-green" } });
|
|
642
|
+
const res = await probeOnce(p, exec, {});
|
|
643
|
+
assertEquals(res.ready, true, "a failed event read leaves the rollup event-less — a single green run is still green");
|
|
644
|
+
});
|
|
645
|
+
|
|
557
646
|
// ── #857: an epic target is a CANONICAL planKey (`owner/repo#N`) — never a URL ────────────────────
|
|
558
647
|
// The epic probe keys off the durable lineage root (`owner/repo#N`, `parseIssue`'s `planKey` shape).
|
|
559
648
|
// Delegating its validation to `parsePr` (whose grammar includes GitHub PR URLs) let a URL pass the
|
|
@@ -780,7 +869,7 @@ function prObs(over: Partial<PrObservation> = {}): PrObservation {
|
|
|
780
869
|
totalChecks: 0,
|
|
781
870
|
presentCheckNames: [],
|
|
782
871
|
pendingCheckNames: [],
|
|
783
|
-
|
|
872
|
+
checkGateConclusions: {},
|
|
784
873
|
isDraft: false,
|
|
785
874
|
headRefOid: "abc123",
|
|
786
875
|
mergedSha: null,
|
package/app/readiness.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// any credential is read at execution time from the typed env-contract (`credentialEnv` names a
|
|
19
19
|
// declared {@link EnvKey}; ADR 0004 pinned decision 2) and is redacted from every log line.
|
|
20
20
|
import { isEnvKey, readEnv, readEnvOr } from "./contracts.ts";
|
|
21
|
-
import { allCheckNames,
|
|
21
|
+
import { allCheckNames, applyRollupEvents, CHECK_EVENTS_QUERY, checkGateConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
|
|
22
22
|
import { parsePr } from "./prParse.ts";
|
|
23
23
|
import { isoDuration, isoDurationToMs } from "./reviewWait.ts";
|
|
24
24
|
|
|
@@ -764,7 +764,7 @@ export function parsePrView(payload: unknown): PrObservation {
|
|
|
764
764
|
totalChecks: rollup.length,
|
|
765
765
|
presentCheckNames: allCheckNames(rollup),
|
|
766
766
|
pendingCheckNames: pending,
|
|
767
|
-
|
|
767
|
+
checkGateConclusions: checkGateConclusions(rollup),
|
|
768
768
|
isDraft: j.isDraft === true,
|
|
769
769
|
headRefOid: str(j.headRefOid).trim() || null,
|
|
770
770
|
mergeQueueEntry: null, // readiness probe doesn't read native-queue membership (merge-loop-only signal)
|
|
@@ -857,6 +857,76 @@ export function prViewCommand(repo: string, number: string): string {
|
|
|
857
857
|
return `gh pr view ${shellQuote(number)} --repo ${shellQuote(repo)} --json ${shellQuote("state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid,mergeCommit")}`;
|
|
858
858
|
}
|
|
859
859
|
|
|
860
|
+
/** The GraphQL page query backing {@link enrichPrViewEvents}, as a single-quoted shell argument.
|
|
861
|
+
* Mirrors the merge loop's enrichment (`enrichRollupEvents` in `app/github.ts`) but runs through the
|
|
862
|
+
* probe's injected {@link ProbeExec} so the readiness path stays host-agnostic and unit-testable. */
|
|
863
|
+
function prCheckEventsQuery(): string {
|
|
864
|
+
return CHECK_EVENTS_QUERY;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Enrich a `pr`-probe rollup with each run's triggering event, through the probe's injected
|
|
868
|
+
* executor. The `gh pr view --json statusCheckRollup` read omits `event`, so without this the
|
|
869
|
+
* readiness path groups by name alone and a green `push` run hides a red required `pull_request` run
|
|
870
|
+
* of the same name — `checks-green` then reports ready while the PR run is red (PR #957 review).
|
|
871
|
+
* Pages `statusCheckRollup.contexts` to exhaustion and pins the read to the rollup's head SHA
|
|
872
|
+
* (aborting the stamp when the head moved between the two reads), exactly like the merge loop's
|
|
873
|
+
* `enrichRollupEvents`. Never throws: any transport/parse failure leaves the rollup event-less. */
|
|
874
|
+
async function enrichPrViewEvents(
|
|
875
|
+
exec: ProbeExec,
|
|
876
|
+
env: Record<string, string | undefined>,
|
|
877
|
+
repo: string,
|
|
878
|
+
number: string,
|
|
879
|
+
payload: Record<string, unknown>,
|
|
880
|
+
): Promise<void> {
|
|
881
|
+
const j = payload;
|
|
882
|
+
if (!Array.isArray(j.statusCheckRollup) || j.statusCheckRollup.length === 0) return;
|
|
883
|
+
const rollup = j.statusCheckRollup;
|
|
884
|
+
const headSha = typeof j.headRefOid === "string" ? j.headRefOid : "";
|
|
885
|
+
const [owner, name] = repo.split("/");
|
|
886
|
+
const nodes: unknown[] = [];
|
|
887
|
+
let after: string | null = null;
|
|
888
|
+
for (;;) {
|
|
889
|
+
const args = [
|
|
890
|
+
"api",
|
|
891
|
+
"graphql",
|
|
892
|
+
"-f",
|
|
893
|
+
`query=${prCheckEventsQuery()}`,
|
|
894
|
+
"-f",
|
|
895
|
+
`o=${owner}`,
|
|
896
|
+
"-f",
|
|
897
|
+
`r=${name}`,
|
|
898
|
+
"-F",
|
|
899
|
+
`n=${number}`,
|
|
900
|
+
];
|
|
901
|
+
if (after !== null) args.push("-f", `after=${after}`);
|
|
902
|
+
const command = `gh ${args.map(shellQuote).join(" ")}`;
|
|
903
|
+
const out = await exec.run(command, env);
|
|
904
|
+
if (out.code !== 0) return; // a transport hiccup leaves the rollup event-less (name-only gates)
|
|
905
|
+
const page = parseJson(out.stdout);
|
|
906
|
+
if (!isRecord(page)) return;
|
|
907
|
+
const data = isRecord(page.data) ? page.data : undefined;
|
|
908
|
+
const repository = data && isRecord(data.repository) ? data.repository : undefined;
|
|
909
|
+
const pullRequest = repository && isRecord(repository.pullRequest) ? repository.pullRequest : undefined;
|
|
910
|
+
const commits = pullRequest && isRecord(pullRequest.commits) ? pullRequest.commits : undefined;
|
|
911
|
+
const firstCommit = commits && Array.isArray(commits.nodes) ? commits.nodes[0] : undefined;
|
|
912
|
+
const commit = isRecord(firstCommit) && isRecord(firstCommit.commit) ? firstCommit.commit : undefined;
|
|
913
|
+
if (!isRecord(commit)) return;
|
|
914
|
+
// Head-pin: the rollup came from the earlier `gh pr view`; if the head moved since, these
|
|
915
|
+
// contexts belong to a different head — stamping them would join events across heads.
|
|
916
|
+
const oid = typeof commit.oid === "string" ? commit.oid : "";
|
|
917
|
+
if (headSha !== "" && oid !== "" && oid !== headSha) return;
|
|
918
|
+
const rollupObj = isRecord(commit.statusCheckRollup) ? commit.statusCheckRollup : undefined;
|
|
919
|
+
const contexts = rollupObj && isRecord(rollupObj.contexts) ? rollupObj.contexts : undefined;
|
|
920
|
+
if (contexts === undefined) return;
|
|
921
|
+
if (Array.isArray(contexts.nodes)) nodes.push(...contexts.nodes);
|
|
922
|
+
const pageInfo = isRecord(contexts.pageInfo) ? contexts.pageInfo : undefined;
|
|
923
|
+
const endCursor = pageInfo && typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : "";
|
|
924
|
+
if (pageInfo?.hasNextPage !== true || endCursor === "") break;
|
|
925
|
+
after = endCursor;
|
|
926
|
+
}
|
|
927
|
+
applyRollupEvents(rollup, nodes);
|
|
928
|
+
}
|
|
929
|
+
|
|
860
930
|
// ── Epic / plan-fanout probe (issue #568 — gate a graph on an epic reaching "fully merged") ──────
|
|
861
931
|
|
|
862
932
|
/** A live epic (plan-fanout) observation, reduced to the fields {@link matchEpic} reads. An nwf epic
|
|
@@ -974,7 +1044,15 @@ export async function probeOnce(
|
|
|
974
1044
|
if (!ref) return { ready: false, detail: "pr: unparseable target (not ready)" };
|
|
975
1045
|
const out = await exec.run(prViewCommand(ref.repo, ref.number), env);
|
|
976
1046
|
if (out.code !== 0) return { ready: false, detail: "pr: gh pr view failed (not ready)" };
|
|
977
|
-
|
|
1047
|
+
const payload = parseJson(out.stdout);
|
|
1048
|
+
if (!isRecord(payload)) return matchPr(probe.match, parsePrView(payload));
|
|
1049
|
+
// Normalise each run's triggering event BEFORE parsing (PR #957 review): the `gh pr view`
|
|
1050
|
+
// rollup omits `event`, so an un-enriched read groups by name alone and a green `push` run
|
|
1051
|
+
// hides a red required `pull_request` run of the same name — `checks-green` would report
|
|
1052
|
+
// ready while the PR run is red. Shared transport logic with the merge loop, over the
|
|
1053
|
+
// injected executor.
|
|
1054
|
+
await enrichPrViewEvents(exec, env, ref.repo, ref.number, payload);
|
|
1055
|
+
return matchPr(probe.match, parsePrView(payload));
|
|
978
1056
|
}
|
|
979
1057
|
case "epic": {
|
|
980
1058
|
// "Fully merged" is the app's AGGREGATE, not a GitHub read — observe it over the app's own
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.210.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.210.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.210.1",
|
|
3
|
+
"gitSha": "e2c4e966b566ce8b4bdc19e84bceb130909a981c",
|
|
4
|
+
"gitTag": "v0.210.1",
|
|
5
|
+
"builtAt": "2026-11-12T12:00:58.232Z"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.210.
|
|
3
|
+
"version": "0.210.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -18,7 +18,7 @@ function state(over: Partial<PrState>): PrState {
|
|
|
18
18
|
failingCheckNames: [],
|
|
19
19
|
presentCheckNames: ["build", "Mergify Merge Queue"],
|
|
20
20
|
pendingCheckNames: [],
|
|
21
|
-
|
|
21
|
+
checkGateConclusions: { build: ["SUCCESS"], "Mergify Merge Queue": ["NEUTRAL"] },
|
|
22
22
|
totalChecks: 2,
|
|
23
23
|
isDraft: false,
|
|
24
24
|
headRefOid: "abc",
|
|
@@ -80,7 +80,7 @@ test("merge-stall-probe: BLOCKED gh-merge PR with green checks still waits (#951
|
|
|
80
80
|
// must NOT classify that head's stale check failures as `blocked` (which starts fix-ci / escalates).
|
|
81
81
|
// It defers to `waiting` (bounded re-poll) until the head moves or the remedy's window lapses.
|
|
82
82
|
test("merge-stall-probe: a blocked verdict on a pending-update old head defers to waiting (#951)", async () => {
|
|
83
|
-
const blocked = state({ failingChecks: 1, failingCheckNames: ["build"],
|
|
83
|
+
const blocked = state({ failingChecks: 1, failingCheckNames: ["build"], checkGateConclusions: { build: ["FAILURE"] } });
|
|
84
84
|
// Sanity: without a pending update this IS blocked (would route to fix-ci).
|
|
85
85
|
assertEquals((await run(blocked, MERGIFY, false)).mergeState, "blocked");
|
|
86
86
|
// With the update still pending on this head, the probe holds remediation.
|