@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.
@@ -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
- checkConclusions: {},
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, checkConclusions, classifyMergeability, failingCheckNames, type PrState, pendingCheckNames } from "./github.ts";
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
- checkConclusions: checkConclusions(rollup),
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
- return matchPr(probe.match, parsePrView(parseJson(out.stdout)));
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.0",
3
- "gitSha": "c1a72ce90744ccd23aa8160d20f0add2779db837",
4
- "gitTag": "v0.210.0",
5
- "builtAt": "2026-11-12T10:53:21.705Z"
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.0",
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",
@@ -12,7 +12,7 @@ const base: PrState = {
12
12
  totalChecks: 0,
13
13
  presentCheckNames: [],
14
14
  pendingCheckNames: [],
15
- checkConclusions: {},
15
+ checkGateConclusions: {},
16
16
  isDraft: false,
17
17
  headRefOid: null,
18
18
  mergeQueueEntry: null,
@@ -18,7 +18,7 @@ function state(over: Partial<PrState>): PrState {
18
18
  failingCheckNames: [],
19
19
  presentCheckNames: ["build", "Mergify Merge Queue"],
20
20
  pendingCheckNames: [],
21
- checkConclusions: { build: "SUCCESS", "Mergify Merge Queue": "NEUTRAL" },
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"], checkConclusions: { build: "FAILURE" } });
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.