@nanobpm/nano-workforce 0.209.1 → 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 +12 -0
- package/SPEC.md +11 -1
- package/app/behindBaseRemedy.test.ts +1 -1
- package/app/convergeAdversarial.test.ts +152 -0
- package/app/failingChecksSupersede.test.ts +288 -1
- package/app/feature.test.ts +31 -0
- package/app/feature.ts +12 -0
- package/app/github.test.ts +200 -10
- package/app/github.ts +365 -59
- package/app/maybeEnsureFreshHeadRun.test.ts +1 -1
- package/app/mergeLoopBehaviour.test.ts +48 -0
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/readiness.test.ts +90 -1
- package/app/readiness.ts +81 -3
- package/app/service.test.ts +125 -1
- package/app/service.ts +63 -1
- package/build-info.json +4 -4
- package/db/migrations/134_pull_requests_custom_instructions.sql +8 -0
- package/db/migrations/135_pull_requests_read_model_custom_instructions.sql +52 -0
- package/db/migrations/136_pull_requests_custom_instructions_text.sql +11 -0
- package/db/migrations/137_feature_runs_custom_instructions.sql +66 -0
- package/db/migrations/138_pull_requests_read_model_custom_instructions_text.sql +53 -0
- package/docs/agent-guide.md +1 -0
- package/openapi.yaml +24 -0
- package/operations/listActivePrs.test.ts +17 -0
- package/operations/startAndMessage.test.ts +80 -0
- package/operations/startConvergenceLoop.ts +13 -1
- package/package.json +1 -1
- package/pages/home.page.json +7 -2
- package/resources/processes/convergence-loop.bpmn +8 -2
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/merge-loop.bpmn +6 -2
- package/workers/converge-feature/worker.ts +10 -1
- package/workers/landed-probe/worker.test.ts +1 -1
- package/workers/merge-stall-probe/worker.test.ts +2 -2
- package/workers/reconverge/worker.test.ts +50 -0
- package/workers/reconverge/worker.ts +7 -0
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, hasCopilotWorkStarted, classifyBlockedCause, copilotWorkStartedSince, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, checkGateConclusions, hasCopilotWorkStarted, classifyBlockedCause, copilotWorkStartedSince, classifyMergeability, classifyPrLiveness, coalesceTitle, compareCommits, createPullRequest, ensureBaseBranch, ensurePromotionPr, failingCheckNames, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, THREAD_PROBE_FAILED, updateBranchRef, type GhReview, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -551,7 +551,7 @@ function prState(over: Partial<PrState>): PrState {
|
|
|
551
551
|
failingCheckNames: [],
|
|
552
552
|
presentCheckNames: [],
|
|
553
553
|
pendingCheckNames: [],
|
|
554
|
-
|
|
554
|
+
checkGateConclusions: {},
|
|
555
555
|
totalChecks: 0,
|
|
556
556
|
isDraft: false,
|
|
557
557
|
headRefOid: null,
|
|
@@ -601,7 +601,7 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
|
|
|
601
601
|
totalChecks: 0,
|
|
602
602
|
presentCheckNames: [],
|
|
603
603
|
pendingCheckNames: [],
|
|
604
|
-
|
|
604
|
+
checkGateConclusions: {},
|
|
605
605
|
isDraft: false,
|
|
606
606
|
headRefOid: "abc123",
|
|
607
607
|
mergeQueueEntry: null,
|
|
@@ -613,17 +613,20 @@ function mergePrState(over: Partial<PrState> & { rollup?: { name: string; conclu
|
|
|
613
613
|
const present: string[] = [];
|
|
614
614
|
const pending: string[] = [];
|
|
615
615
|
const failing: string[] = [];
|
|
616
|
-
const conclusions: Record<string, string> = {};
|
|
616
|
+
const conclusions: Record<string, string[]> = {};
|
|
617
617
|
for (const c of rollup) {
|
|
618
618
|
const v = c.conclusion.toUpperCase();
|
|
619
619
|
present.push(c.name);
|
|
620
|
-
|
|
620
|
+
// One entry per rollup run of the name — mirrors `checkGateConclusions` (a per-gate list).
|
|
621
|
+
const gates = conclusions[c.name] ?? [];
|
|
622
|
+
gates.push(pendingStates.has(v) ? "" : v);
|
|
623
|
+
conclusions[c.name] = gates;
|
|
621
624
|
if (pendingStates.has(v)) pending.push(c.name);
|
|
622
625
|
else if (bad.has(v)) failing.push(c.name);
|
|
623
626
|
}
|
|
624
627
|
base.presentCheckNames = present;
|
|
625
628
|
base.pendingCheckNames = pending;
|
|
626
|
-
base.
|
|
629
|
+
base.checkGateConclusions = conclusions;
|
|
627
630
|
base.failingCheckNames = failing;
|
|
628
631
|
base.failingChecks = failing.length;
|
|
629
632
|
base.totalChecks = rollup.length;
|
|
@@ -812,13 +815,185 @@ for (const c of TOKEN_MODE) {
|
|
|
812
815
|
totalChecks: -1,
|
|
813
816
|
presentCheckNames: [],
|
|
814
817
|
pendingCheckNames: [],
|
|
815
|
-
|
|
818
|
+
checkGateConclusions: {},
|
|
816
819
|
});
|
|
817
820
|
const protocol = protocolWith({ requiredChecks: reqChecks("build"), waitForChecks: true });
|
|
818
821
|
assertEquals(classifyMergeability(s, protocol), c.want);
|
|
819
822
|
});
|
|
820
823
|
}
|
|
821
824
|
|
|
825
|
+
// ── Same-named checks from different triggering events are different gates (issue #955) ─────────
|
|
826
|
+
//
|
|
827
|
+
// One workflow file with both `pull_request` and `push` jobs named `build` puts two runs named
|
|
828
|
+
// `build` on the same head SHA. Grouping the rollup by display name alone kept the NEWER run, so a
|
|
829
|
+
// red required `pull_request` `build` was collapsed away by a green `push` `build` 4s later —
|
|
830
|
+
// `failingChecks` read 0, `classifyBlockedCause` could not see the failed required check, the
|
|
831
|
+
// poller classified `waiting`, and `senior:fix-ci` never ran (camunda/console-api-js#17). These
|
|
832
|
+
// tests walk the exact observed rollup through the derivation helpers into the classifier — no
|
|
833
|
+
// merge-protocol file required.
|
|
834
|
+
|
|
835
|
+
test("#955 classifier walk: red PR `build` + newer green push `build` + pending CLA, BLOCKED -> blocked", () => {
|
|
836
|
+
// The exact head `641c7ee9` rollup shape from camunda/console-api-js#17.
|
|
837
|
+
const rollup = [
|
|
838
|
+
{ name: "build", event: "pull_request", conclusion: "FAILURE", status: "COMPLETED", startedAt: "2026-11-12T00:30:00Z", completedAt: "2026-11-12T00:41:34Z" },
|
|
839
|
+
{ name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", startedAt: "2026-11-12T00:41:30Z", completedAt: "2026-11-12T00:41:38Z" },
|
|
840
|
+
{ context: "license/cla", state: "PENDING", createdAt: "2026-11-12T00:30:05Z" },
|
|
841
|
+
];
|
|
842
|
+
const names = failingCheckNames(rollup);
|
|
843
|
+
assertEquals(names, ["build"], "the red required pull_request run survives the collapse and names the gate");
|
|
844
|
+
const s = prState({
|
|
845
|
+
mergeStateStatus: "BLOCKED",
|
|
846
|
+
failingChecks: names.length,
|
|
847
|
+
failingCheckNames: names,
|
|
848
|
+
unresolvedThreads: 0,
|
|
849
|
+
});
|
|
850
|
+
// No merge-protocol file on the repo — the default protocol declares no requiredChecks.
|
|
851
|
+
assertEquals(classifyBlockedCause(s), "checks");
|
|
852
|
+
assertEquals(classifyMergeability(s), "blocked", "the merge loop must route to fix-ci, not park on wait-mergeable");
|
|
853
|
+
assertEquals(classifyMergeability(s, DEFAULT_MERGE_PROTOCOL), "blocked");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("#955 classifier walk: the same rollup ALSO blocks a ruleset-declared required `build`", () => {
|
|
857
|
+
// Even with #901's ruleset-sourced requiredChecks, the collapsed-away red run made
|
|
858
|
+
// `requiredChecksVerdict` return "pass". With the fix the required `build` reads FAILURE.
|
|
859
|
+
const rollup = [
|
|
860
|
+
{ name: "build", event: "pull_request", conclusion: "FAILURE", status: "COMPLETED", completedAt: "2026-11-12T00:41:34Z" },
|
|
861
|
+
{ name: "build", event: "push", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
|
|
862
|
+
];
|
|
863
|
+
const conclusions = checkGateConclusions(rollup);
|
|
864
|
+
assertEquals(conclusions.build, ["FAILURE", "SUCCESS"], "each retained gate keeps its own conclusion");
|
|
865
|
+
const s = prState({
|
|
866
|
+
mergeStateStatus: "BLOCKED",
|
|
867
|
+
failingChecks: 1,
|
|
868
|
+
failingCheckNames: ["build"],
|
|
869
|
+
presentCheckNames: ["build"],
|
|
870
|
+
checkGateConclusions: conclusions,
|
|
871
|
+
unresolvedThreads: 0,
|
|
872
|
+
});
|
|
873
|
+
assertEquals(classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })), "blocked");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("#955: a genuinely non-required red check of a colliding name does not newly block a CLEAN PR", () => {
|
|
877
|
+
// UNSTABLE + only an OPTIONAL red check stays "ready" — the fix must not turn every cross-event
|
|
878
|
+
// red run into a merge block when GitHub itself says only non-required checks failed.
|
|
879
|
+
const rollup = [
|
|
880
|
+
{ name: "build", event: "pull_request", conclusion: "SUCCESS", status: "COMPLETED", completedAt: "2026-11-12T00:41:34Z" },
|
|
881
|
+
{ name: "build", event: "push", conclusion: "FAILURE", status: "COMPLETED", completedAt: "2026-11-12T00:41:38Z" },
|
|
882
|
+
];
|
|
883
|
+
const names = failingCheckNames(rollup);
|
|
884
|
+
assertEquals(names, ["build"], "the optional red push run is still reported as failing");
|
|
885
|
+
const s = prState({
|
|
886
|
+
mergeStateStatus: "UNSTABLE",
|
|
887
|
+
failingChecks: names.length,
|
|
888
|
+
failingCheckNames: names,
|
|
889
|
+
unresolvedThreads: -1,
|
|
890
|
+
});
|
|
891
|
+
assertEquals(classifyMergeability(s), "ready", "UNSTABLE means only non-required checks failing — still mergeable");
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("#955: token mode (failingChecks === -1) is unchanged", () => {
|
|
895
|
+
const s = prState({
|
|
896
|
+
mergeStateStatus: "BLOCKED",
|
|
897
|
+
failingChecks: -1,
|
|
898
|
+
totalChecks: -1,
|
|
899
|
+
presentCheckNames: [],
|
|
900
|
+
pendingCheckNames: [],
|
|
901
|
+
checkGateConclusions: {},
|
|
902
|
+
unresolvedThreads: 0,
|
|
903
|
+
});
|
|
904
|
+
assertEquals(classifyMergeability(s), "waiting", "unenumerable checks stay conservative — never newly blocked");
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// ── PR #957 review: per-gate conclusions drive the required-check verdict ───────────────────────
|
|
908
|
+
//
|
|
909
|
+
// Same-named runs from different events are different gates, so a required name can carry SEVERAL
|
|
910
|
+
// terminal conclusions. The verdict must (a) route a settled rejection to fix-ci even while a
|
|
911
|
+
// same-named sibling gate is still pending, and (b) require EVERY retained gate to satisfy
|
|
912
|
+
// `acceptedConclusions` — a single accepted gate must not whitewash a rejected sibling.
|
|
913
|
+
|
|
914
|
+
test("#957: a settled FAILURE required gate routes to fix-ci even while a same-named sibling is pending", () => {
|
|
915
|
+
// A red PR `build` plus an in-progress push `build`: pending must not mask the settled failure.
|
|
916
|
+
const s = prState({
|
|
917
|
+
mergeStateStatus: "BLOCKED",
|
|
918
|
+
failingChecks: 1,
|
|
919
|
+
failingCheckNames: ["build"],
|
|
920
|
+
presentCheckNames: ["build"],
|
|
921
|
+
pendingCheckNames: ["build"], // the push gate is still running
|
|
922
|
+
checkGateConclusions: { build: ["FAILURE", ""] }, // PR gate failed, push gate in-flight
|
|
923
|
+
unresolvedThreads: 0,
|
|
924
|
+
});
|
|
925
|
+
assertEquals(
|
|
926
|
+
classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
|
|
927
|
+
"blocked",
|
|
928
|
+
"a settled rejected gate owns remediation now — a pending sibling must not delay fix-ci",
|
|
929
|
+
);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test("#957: a SUCCESS gate does not satisfy a success-only required check while a NEUTRAL sibling gate exists", () => {
|
|
933
|
+
// Every retained gate must be accepted: a green PR `build` + a NEUTRAL push `build` is rejected.
|
|
934
|
+
const s = prState({
|
|
935
|
+
mergeStateStatus: "CLEAN",
|
|
936
|
+
failingChecks: 0,
|
|
937
|
+
presentCheckNames: ["build"],
|
|
938
|
+
pendingCheckNames: [],
|
|
939
|
+
checkGateConclusions: { build: ["SUCCESS", "NEUTRAL"] },
|
|
940
|
+
unresolvedThreads: 0,
|
|
941
|
+
});
|
|
942
|
+
assertEquals(
|
|
943
|
+
classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
|
|
944
|
+
"blocked",
|
|
945
|
+
"a same-named NEUTRAL gate rejects a success-only required check even alongside a SUCCESS gate",
|
|
946
|
+
);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("#957: a SUCCESS gate does not satisfy a success-only required check while a SKIPPED sibling gate exists", () => {
|
|
950
|
+
const s = prState({
|
|
951
|
+
mergeStateStatus: "CLEAN",
|
|
952
|
+
failingChecks: 0,
|
|
953
|
+
presentCheckNames: ["build"],
|
|
954
|
+
pendingCheckNames: [],
|
|
955
|
+
checkGateConclusions: { build: ["SUCCESS", "SKIPPED"] },
|
|
956
|
+
unresolvedThreads: 0,
|
|
957
|
+
});
|
|
958
|
+
assertEquals(
|
|
959
|
+
classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
|
|
960
|
+
"blocked",
|
|
961
|
+
"a same-named SKIPPED gate rejects a success-only required check even alongside a SUCCESS gate",
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("#957: every gate accepted passes; a NEUTRAL sibling is fine when the protocol accepts neutral", () => {
|
|
966
|
+
const s = prState({
|
|
967
|
+
mergeStateStatus: "CLEAN",
|
|
968
|
+
failingChecks: 0,
|
|
969
|
+
presentCheckNames: ["build"],
|
|
970
|
+
pendingCheckNames: [],
|
|
971
|
+
checkGateConclusions: { build: ["SUCCESS", "NEUTRAL"] },
|
|
972
|
+
unresolvedThreads: 0,
|
|
973
|
+
});
|
|
974
|
+
assertEquals(
|
|
975
|
+
classifyMergeability(s, protocolWith({ requiredChecks: [{ name: "build", acceptedConclusions: ["success", "neutral"] }] })),
|
|
976
|
+
"ready",
|
|
977
|
+
"when every retained gate's conclusion is accepted the required check is satisfied",
|
|
978
|
+
);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("#957: a still-pending only gate (no settled conclusion) waits, never passes", () => {
|
|
982
|
+
const s = prState({
|
|
983
|
+
mergeStateStatus: "CLEAN",
|
|
984
|
+
failingChecks: 0,
|
|
985
|
+
presentCheckNames: ["build"],
|
|
986
|
+
pendingCheckNames: ["build"],
|
|
987
|
+
checkGateConclusions: { build: [""] },
|
|
988
|
+
unresolvedThreads: 0,
|
|
989
|
+
});
|
|
990
|
+
assertEquals(
|
|
991
|
+
classifyMergeability(s, protocolWith({ requiredChecks: reqChecks("build") })),
|
|
992
|
+
"waiting",
|
|
993
|
+
"a required check with no settled gate is not-yet-concluded → waiting, not pass",
|
|
994
|
+
);
|
|
995
|
+
});
|
|
996
|
+
|
|
822
997
|
// ── Draft PRs are never landable (issue #454) ────────────────────────────────────────────────────
|
|
823
998
|
//
|
|
824
999
|
// A draft PR with green checks reports `mergeStateStatus: CLEAN`, so the old `mergeStateStatus`
|
|
@@ -1076,7 +1251,7 @@ test("classifyMergeability: a known merge conflict is never suppressed by the th
|
|
|
1076
1251
|
mergeStateStatus: "DIRTY",
|
|
1077
1252
|
failingChecks: 1,
|
|
1078
1253
|
presentCheckNames: ["build"],
|
|
1079
|
-
|
|
1254
|
+
checkGateConclusions: { build: ["FAILURE"] },
|
|
1080
1255
|
unresolvedThreads: 2,
|
|
1081
1256
|
}),
|
|
1082
1257
|
{ requiredChecks: [{ name: "build", acceptedConclusions: ["SUCCESS"] }] },
|
|
@@ -1100,7 +1275,7 @@ test("classifyMergeability: the declared required-check verdict outranks the set
|
|
|
1100
1275
|
mergeStateStatus: "CLEAN",
|
|
1101
1276
|
failingChecks: 0,
|
|
1102
1277
|
presentCheckNames: ["build"],
|
|
1103
|
-
|
|
1278
|
+
checkGateConclusions: { build: ["NEUTRAL"] },
|
|
1104
1279
|
unresolvedThreads: 2,
|
|
1105
1280
|
}),
|
|
1106
1281
|
successOnly,
|
|
@@ -1125,7 +1300,7 @@ test("classifyMergeability: the declared required-check verdict outranks the set
|
|
|
1125
1300
|
mergeStateStatus: "CLEAN",
|
|
1126
1301
|
failingChecks: 0,
|
|
1127
1302
|
presentCheckNames: ["build"],
|
|
1128
|
-
|
|
1303
|
+
checkGateConclusions: { build: ["SUCCESS"] },
|
|
1129
1304
|
unresolvedThreads: 2,
|
|
1130
1305
|
}),
|
|
1131
1306
|
successOnly,
|
|
@@ -1640,3 +1815,18 @@ test("hasCopilotWorkStarted (token): an aborted (timed-out) read is UNKNOWN (nul
|
|
|
1640
1815
|
);
|
|
1641
1816
|
assertEquals(got, null);
|
|
1642
1817
|
});
|
|
1818
|
+
|
|
1819
|
+
test("CHECK_EVENTS_QUERY is brace-balanced (a malformed query leaves the rollup event-less, PR #957)", async () => {
|
|
1820
|
+
const { CHECK_EVENTS_QUERY } = await import("./github.ts");
|
|
1821
|
+
let depth = 0;
|
|
1822
|
+
for (const ch of CHECK_EVENTS_QUERY) {
|
|
1823
|
+
if (ch === "{") depth++;
|
|
1824
|
+
if (ch === "}") depth--;
|
|
1825
|
+
if (depth < 0) throw new Error("closing brace without a matching opening brace");
|
|
1826
|
+
}
|
|
1827
|
+
assertEquals(depth, 0);
|
|
1828
|
+
for (const p of ["(", "["]) {
|
|
1829
|
+
const close = p === "(" ? ")" : "]";
|
|
1830
|
+
assertEquals(CHECK_EVENTS_QUERY.split(p).length, CHECK_EVENTS_QUERY.split(close).length);
|
|
1831
|
+
}
|
|
1832
|
+
});
|