@nanobpm/nano-workforce 0.205.1 → 0.206.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/.github/workflows/release.yml +18 -3
- package/.releaserc.json +7 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.ts +53 -3
- package/app/mergeLoopBehaviour.test.ts +12 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/reviewWait.test.ts +37 -0
- package/app/reviewWait.ts +26 -0
- package/app/roundProgress.test.ts +63 -2
- package/app/roundProgress.ts +6 -0
- package/app/version.test.ts +204 -0
- package/app/version.ts +64 -4
- package/build-info.json +6 -0
- package/docs/agent-guide.md +5 -0
- package/openapi.yaml +38 -0
- package/operations/getVersion.test.ts +3 -0
- package/operations/listActivePrs.test.ts +15 -0
- package/operations/listActivePrs.ts +5 -2
- package/package.json +2 -1
- package/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +1 -1
- package/scripts/write-build-info.test.ts +87 -0
- package/scripts/write-build-info.ts +126 -0
- package/workers/converge-gate/worker.ts +61 -11
- package/workers/merge/worker.test.ts +192 -1
- package/workers/merge/worker.ts +118 -7
- package/workers/progress-check/worker.ts +40 -6
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Tests for scripts/write-build-info.ts — the release-time bake of `build-info.json` (issue #925).
|
|
2
|
+
//
|
|
3
|
+
// The acceptance criterion is end-to-end: an `npm pack` of a tree whose build-info.json was written
|
|
4
|
+
// by this script CONTAINS the file, with a 40-hex gitSha — i.e. the published tarball carries the
|
|
5
|
+
// identity an npm-installed instance falls back to. Plus unit coverage of the payload the script
|
|
6
|
+
// composes (source-HEAD sha, `v<version>` tag, ISO builtAt) and its validation.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
14
|
+
import { buildInfoProblems, composeBuildInfo } from "./write-build-info.ts";
|
|
15
|
+
|
|
16
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const BUILD_INFO = join(REPO_ROOT, "build-info.json");
|
|
18
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
19
|
+
|
|
20
|
+
test("composeBuildInfo records the checkout HEAD (40-hex), the v<version> tag, and an ISO builtAt", () => {
|
|
21
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT, encoding: "utf8" }).trim();
|
|
22
|
+
const info = composeBuildInfo("1.2.3", new Date("2026-01-02T03:04:05.000Z"));
|
|
23
|
+
assertEquals(info.gitSha, head);
|
|
24
|
+
assert(SHA_RE.test(info.gitSha));
|
|
25
|
+
assertEquals(info.gitTag, "v1.2.3");
|
|
26
|
+
assertEquals(info.builtAt, "2026-01-02T03:04:05.000Z");
|
|
27
|
+
assertEquals(buildInfoProblems(info), []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("composeBuildInfo honours an explicit source SHA instead of the checkout HEAD", () => {
|
|
31
|
+
// The manual OIDC-recovery path checks out the release TAG, whose HEAD is the metadata-only
|
|
32
|
+
// release commit — so it passes the source HEAD explicitly. The explicit SHA must win over
|
|
33
|
+
// `git rev-parse HEAD` and must be validated against the 40-hex contract.
|
|
34
|
+
const sourceHead = "b".repeat(40);
|
|
35
|
+
const info = composeBuildInfo("2.0.0", new Date("2026-01-02T03:04:05.000Z"), sourceHead);
|
|
36
|
+
assertEquals(info.gitSha, sourceHead);
|
|
37
|
+
assertEquals(info.gitTag, "v2.0.0");
|
|
38
|
+
assertEquals(buildInfoProblems(info), []);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("composeBuildInfo rejects a malformed explicit source SHA (fail closed)", () => {
|
|
42
|
+
assertThrows(() => composeBuildInfo("2.0.0", new Date(), "not-a-sha"), Error, "non-SHA");
|
|
43
|
+
assertThrows(() => composeBuildInfo("2.0.0", new Date(), "abc123"), Error, "non-SHA");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("buildInfoProblems rejects malformed payloads", () => {
|
|
47
|
+
assert(buildInfoProblems(null).length > 0);
|
|
48
|
+
assert(buildInfoProblems({ version: "1.0.0", gitSha: "not-a-sha", gitTag: "v1.0.0", builtAt: "2026-01-02T03:04:05.000Z" }).length > 0);
|
|
49
|
+
assert(buildInfoProblems({ version: "1.0.0", gitSha: "a".repeat(40), gitTag: "", builtAt: "2026-01-02T03:04:05.000Z" }).length > 0);
|
|
50
|
+
assert(buildInfoProblems({ version: "1.0.0", gitSha: "a".repeat(40), gitTag: "v1.0.0", builtAt: "not-a-date" }).length > 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("an npm pack of the package contains build-info.json with a 40-hex SHA", () => {
|
|
54
|
+
// Write the file exactly as the release pipeline does (prepare step), pack, inspect, then clean
|
|
55
|
+
// up — build-info.json is a build artifact and MUST NOT be committed (a stale committed copy
|
|
56
|
+
// would mislabel every later dev checkout), so the test restores the prior state in `finally`.
|
|
57
|
+
const prior = (() => {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(BUILD_INFO, "utf8");
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
const packDir = mkdtempSync(join(tmpdir(), "nwf-pack-"));
|
|
65
|
+
try {
|
|
66
|
+
execFileSync("node", ["--experimental-strip-types", "scripts/write-build-info.ts", "0.0.0-packtest"], {
|
|
67
|
+
cwd: REPO_ROOT,
|
|
68
|
+
stdio: "pipe",
|
|
69
|
+
});
|
|
70
|
+
const out = execFileSync("npm", ["pack", "--pack-destination", packDir], { cwd: REPO_ROOT, encoding: "utf8" });
|
|
71
|
+
const tarball = join(packDir, out.trim().split("\n").pop() ?? "");
|
|
72
|
+
const listing = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" });
|
|
73
|
+
assert(
|
|
74
|
+
listing.split("\n").some((f) => f === "package/build-info.json" || f === "build-info.json"),
|
|
75
|
+
`the tarball must contain build-info.json; got:\n${listing.split("\n").slice(0, 5).join("\n")}…`,
|
|
76
|
+
);
|
|
77
|
+
const raw = execFileSync("tar", ["-xzOf", tarball, "package/build-info.json"], { encoding: "utf8" });
|
|
78
|
+
const packed: unknown = JSON.parse(raw);
|
|
79
|
+
assertEquals(buildInfoProblems(packed), [], `packed build-info.json must be well-formed: ${raw}`);
|
|
80
|
+
const gitSha = (packed as { gitSha: string }).gitSha;
|
|
81
|
+
assert(SHA_RE.test(gitSha), `packed gitSha must be 40-hex, got ${gitSha}`);
|
|
82
|
+
} finally {
|
|
83
|
+
if (prior == null) rmSync(BUILD_INFO, { force: true });
|
|
84
|
+
else writeFileSync(BUILD_INFO, prior);
|
|
85
|
+
rmSync(packDir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// write-build-info — bake the release's build identity into `build-info.json` at the package root
|
|
2
|
+
// (issue #925).
|
|
3
|
+
//
|
|
4
|
+
// WHY: an npm-installed instance has no `.git` directory and no `NANO_WORKFORCE_GIT_SHA` env var,
|
|
5
|
+
// so `app/version.ts` reported `gitSha: null` on exactly the deploys an operator most needs to
|
|
6
|
+
// identify ("is the fix live on merlin?"). The release pipeline writes this file during
|
|
7
|
+
// semantic-release's `prepare` step — AFTER @semantic-release/npm bumps `package.json` to the
|
|
8
|
+
// release version and BEFORE it packs/publishes the tarball — so every published package carries
|
|
9
|
+
// its own identity and `app/version.ts` can fall back to it (env → .git → build-info.json).
|
|
10
|
+
//
|
|
11
|
+
// WHAT IT RECORDS (and why): `gitSha` is the SOURCE HEAD the release was cut from
|
|
12
|
+
// (`git rev-parse HEAD` in the release checkout), NOT the `chore(release): X.Y.Z` commit
|
|
13
|
+
// @semantic-release/git creates afterwards — that commit does not exist yet at prepare time, so it
|
|
14
|
+
// cannot be recorded here. The release TAG `vX.Y.Z` is later placed on that release commit, whose
|
|
15
|
+
// tree is identical to the source HEAD's tree except for the version-bump metadata (CHANGELOG.md,
|
|
16
|
+
// package.json, package-lock.json). `gitTag` carries the tag name so an operator can resolve the
|
|
17
|
+
// exact release commit (`git rev-list -1 <tag>`) when they need it; `gitSha` answers the
|
|
18
|
+
// day-to-day question "which source code is running".
|
|
19
|
+
//
|
|
20
|
+
// USAGE:
|
|
21
|
+
// node --experimental-strip-types scripts/write-build-info.ts <version> [--sha <40-hex>] # release pipeline
|
|
22
|
+
// node --experimental-strip-types scripts/write-build-info.ts --check # test: validate on disk
|
|
23
|
+
//
|
|
24
|
+
// `<version>` alone records the current checkout HEAD — correct at semantic-release `prepare` time.
|
|
25
|
+
// The manual OIDC-recovery path runs from a fresh checkout of the release TAG, whose HEAD is the
|
|
26
|
+
// metadata-only `chore(release)` commit, NOT the source HEAD; it passes `--sha "$(git rev-parse
|
|
27
|
+
// v<version>^")"` so the recorded gitSha still names the source code (see release.yml's recovery
|
|
28
|
+
// instructions).
|
|
29
|
+
//
|
|
30
|
+
// `--check` validates the committed/generated `build-info.json` (present, well-formed, 40-hex
|
|
31
|
+
// gitSha) — used by the pack test to assert the release artifact contract without cutting a
|
|
32
|
+
// release.
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
37
|
+
|
|
38
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
39
|
+
export const BUILD_INFO_PATH = join(REPO_ROOT, "build-info.json");
|
|
40
|
+
|
|
41
|
+
export interface BuildInfo {
|
|
42
|
+
version: string;
|
|
43
|
+
gitSha: string;
|
|
44
|
+
gitTag: string;
|
|
45
|
+
builtAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The ONE payload validator is shared with the reader (app/version.ts) so writer and reader can
|
|
49
|
+
// never disagree on what a valid build-info.json is.
|
|
50
|
+
export { buildInfoProblems } from "../app/version.ts";
|
|
51
|
+
|
|
52
|
+
import { buildInfoProblems, SHA_RE } from "../app/version.ts";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compose the build-info payload for a release of `version`. `gitSha` is the SOURCE HEAD the
|
|
56
|
+
* release was cut from. By default it is read from the current checkout HEAD (`git rev-parse HEAD`)
|
|
57
|
+
* — correct at semantic-release `prepare` time, when HEAD is still the source commit. Callers that
|
|
58
|
+
* run LATER (the manual OIDC-recovery path, which checks out the release TAG) must pass `sha`
|
|
59
|
+
* explicitly as the source HEAD (`git rev-parse v<version>^`): by then HEAD is the metadata-only
|
|
60
|
+
* `chore(release)` commit, and recording it would mislabel the package with a commit whose only
|
|
61
|
+
* change is the version bump. An explicit `sha` is validated against the 40-hex contract.
|
|
62
|
+
*/
|
|
63
|
+
export function composeBuildInfo(version: string, now: Date = new Date(), sha?: string): BuildInfo {
|
|
64
|
+
const gitSha = (
|
|
65
|
+
sha ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT, encoding: "utf8" })
|
|
66
|
+
).trim();
|
|
67
|
+
if (!SHA_RE.test(gitSha)) throw new Error(`git rev-parse HEAD returned a non-SHA: ${JSON.stringify(gitSha)}`);
|
|
68
|
+
return {
|
|
69
|
+
version,
|
|
70
|
+
gitSha,
|
|
71
|
+
// The tag semantic-release will place on the release commit for this version.
|
|
72
|
+
gitTag: `v${version}`,
|
|
73
|
+
builtAt: now.toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function main(argv: string[]): void {
|
|
78
|
+
const arg = argv[2];
|
|
79
|
+
if (arg === "--check") {
|
|
80
|
+
// Lazy import so the write path never needs fs/readFileSync beyond writeFileSync.
|
|
81
|
+
import("node:fs").then(({ readFileSync }) => {
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(BUILD_INFO_PATH, "utf8"));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// biome-ignore lint/plugin: narrowing a caught unknown to Error for its message
|
|
87
|
+
console.error(`build-info.json is missing or invalid JSON: ${(err as Error).message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const problems = buildInfoProblems(parsed);
|
|
91
|
+
if (problems.length > 0) {
|
|
92
|
+
console.error(`build-info.json failed validation:\n - ${problems.join("\n - ")}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
console.log(`build-info.json OK: ${JSON.stringify(parsed)}`);
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const version = arg?.trim();
|
|
100
|
+
if (!version) {
|
|
101
|
+
console.error("usage: write-build-info.ts <version> [--sha <40-hex>] | --check");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
// Optional explicit source SHA (see composeBuildInfo). The manual OIDC-recovery path checks out
|
|
105
|
+
// the release TAG, whose HEAD is the release commit — so it passes the source HEAD
|
|
106
|
+
// (`git rev-parse v<version>^`) explicitly to keep gitSha pointing at the source code.
|
|
107
|
+
let sha: string | undefined;
|
|
108
|
+
const shaIdx = argv.indexOf("--sha");
|
|
109
|
+
if (shaIdx !== -1) {
|
|
110
|
+
sha = argv[shaIdx + 1]?.trim();
|
|
111
|
+
if (!sha || !SHA_RE.test(sha)) {
|
|
112
|
+
console.error(`--sha must be a 40-hex commit SHA, got ${JSON.stringify(argv[shaIdx + 1])}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const info = composeBuildInfo(version, new Date(), sha);
|
|
117
|
+
writeFileSync(BUILD_INFO_PATH, `${JSON.stringify(info, null, 2)}\n`);
|
|
118
|
+
console.log(`wrote build-info.json: version=${info.version} gitSha=${info.gitSha} gitTag=${info.gitTag}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run the CLI only when executed directly — importing this module (e.g. from the test) must not
|
|
122
|
+
// parse argv or write files.
|
|
123
|
+
const invokedAs = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
124
|
+
if (invokedAs === import.meta.url) {
|
|
125
|
+
main(process.argv);
|
|
126
|
+
}
|
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
// re-enters the review wait for a fresh review of the current HEAD. The head read fails OPEN, so a
|
|
30
30
|
// transport hiccup can never fabricate a stale verdict.
|
|
31
31
|
//
|
|
32
|
+
// MISSING-REVIEW GUARD (issue #923): the same `reviewStale` signal fires when NO Copilot review
|
|
33
|
+
// exists at all — the gate's single most important precondition. The old code treated
|
|
34
|
+
// `{ body: "", commitId: null }` as "a verified clean review" and converged, merging
|
|
35
|
+
// nano-bpm#1443 with ZERO reviews. A missing review now fails CLOSED into the re-solicit path.
|
|
36
|
+
//
|
|
37
|
+
// VERDICT GATE (issue #923 §2): even a head-current review BLOCKS when its own summary reports
|
|
38
|
+
// open, actionable findings ("Changes recommended" / "N open issue(s)" / a v2 "Open (N)" section)
|
|
39
|
+
// — zero unresolved threads is not a clean verdict.
|
|
40
|
+
//
|
|
32
41
|
// Scope integrity is NO LONGER judged here. A deterministic regex over the PR description could not
|
|
33
42
|
// read the closed issue's acceptance criteria, so it false-positived on any body that merely
|
|
34
43
|
// *mentioned* deferral (an ADR non-goal, a PR whose subject is scope tooling) and forced needless
|
|
@@ -49,10 +58,11 @@ import {
|
|
|
49
58
|
fetchReviewThreads,
|
|
50
59
|
isAckThread,
|
|
51
60
|
parseAckedAdvisories,
|
|
61
|
+
parseReviewOpenFindings,
|
|
52
62
|
parseSuppressedAdvisories,
|
|
53
63
|
type ReviewThread,
|
|
54
64
|
} from "../../app/github.ts";
|
|
55
|
-
import {
|
|
65
|
+
import { isReviewMissingOrStale } from "../../app/reviewWait.ts";
|
|
56
66
|
import { parsePr } from "../../app/service.ts";
|
|
57
67
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
58
68
|
import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
|
|
@@ -118,17 +128,57 @@ export function makeHandler(deps: {
|
|
|
118
128
|
if (review === null) {
|
|
119
129
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
|
|
120
130
|
}
|
|
121
|
-
// STALE-REVIEW GUARD (
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
131
|
+
// STALE-OR-MISSING-REVIEW GUARD (issues #799 / #923). Convergence requires a HEAD-CURRENT
|
|
132
|
+
// reviewer verdict. Two states fail that precondition and must route to the review wait (the
|
|
133
|
+
// poller (re-)solicits a review of the current HEAD) rather than be gated on:
|
|
134
|
+
// • STALE (#799): the PR HEAD has advanced PAST the commit the latest Copilot review was
|
|
135
|
+
// submitted against — its suppressed advisories describe code the head has moved past
|
|
136
|
+
// (e.g. an advisory the agent already FIXED IN CODE but did not ack), so blocking on the
|
|
137
|
+
// obsolete body re-escalates a human indefinitely (PR #789).
|
|
138
|
+
// • MISSING (#923): NO Copilot review exists at all (`commitId: null` with a readable
|
|
139
|
+
// head). The old gate treated that as "a verified clean review" — zero threads, zero
|
|
140
|
+
// advisories → not blocked — and merged nano-bpm#1443 with ZERO reviews. A missing
|
|
141
|
+
// review is the gate's single most important precondition, so it fails CLOSED into the
|
|
142
|
+
// same re-solicit path (`isReviewMissingOrStale`), never through to finalize.
|
|
143
|
+
// The head read still fails OPEN (null → not stale): without the head we can prove nothing,
|
|
144
|
+
// so a transport hiccup can never fabricate a stale/missing verdict that would loop the loop
|
|
145
|
+
// re-soliciting forever — the review-wait timeout remains the backstop.
|
|
146
|
+
//
|
|
147
|
+
// ARRIVAL-WINDOW CUTOFF (issue #923 review, "previously missed"): the stale/missing verdict
|
|
148
|
+
// is decided from the review read ABOVE — but the loop only PARKS for the next review several
|
|
149
|
+
// tasks later (persist-round → check-progress), and check-progress stamps `waiting_since` at
|
|
150
|
+
// THAT (later) park time. A head-current review that lands in the window BETWEEN this read
|
|
151
|
+
// and the park would then be REJECTED by `pollReviews` (it filters
|
|
152
|
+
// `submitted_at >= waiting_since`), so the loop re-solicits and can time out despite a valid
|
|
153
|
+
// verdict already sitting unconsumed on the PR. Emit the time of THIS read as
|
|
154
|
+
// `reviewWaitCutoff` so check-progress can park with `waiting_since` at the cutoff — any
|
|
155
|
+
// review that arrived in the window then satisfies the wait instead of being discarded.
|
|
129
156
|
const headSha = await readHeadSha(ghRepo, ghNumber).catch(() => null);
|
|
130
|
-
if (
|
|
131
|
-
return {
|
|
157
|
+
if (isReviewMissingOrStale(review.commitId, headSha)) {
|
|
158
|
+
return {
|
|
159
|
+
convergeBlocked: false,
|
|
160
|
+
convergeBlockReason: "",
|
|
161
|
+
convergeAckOnly: false,
|
|
162
|
+
reviewStale: true,
|
|
163
|
+
reviewWaitCutoff: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// VERDICT GATE (issue #923 §2): a head-current review whose OWN summary reports open,
|
|
167
|
+
// actionable findings (Copilot's "Changes recommended" / "N open issue(s)" / a v2 "Open (N)"
|
|
168
|
+
// section) blocks the same way an unresolved thread does — even with zero unresolved threads
|
|
169
|
+
// (a review can carry findings as summary-only, as #1443's 7-finding review did). This block
|
|
170
|
+
// is NOT ack-only: the findings need the round agent's code/reply work, so it escalates to a
|
|
171
|
+
// human rather than entering the bounded auto-ack path.
|
|
172
|
+
const openFindings = parseReviewOpenFindings(review.body);
|
|
173
|
+
if (openFindings.length > 0) {
|
|
174
|
+
return {
|
|
175
|
+
convergeBlocked: true,
|
|
176
|
+
convergeBlockReason:
|
|
177
|
+
`Convergence blocked: the latest head-current Copilot review reports ${openFindings.join("; ")}. ` +
|
|
178
|
+
`Address every finding the review raised (or reply-and-resolve why each is a false positive) before converging.`,
|
|
179
|
+
convergeAckOnly: false,
|
|
180
|
+
reviewStale: false,
|
|
181
|
+
};
|
|
132
182
|
}
|
|
133
183
|
// Split the unresolved threads into SUBSTANTIVE reviewer findings vs partially-completed
|
|
134
184
|
// `nano-ack:` ACK threads (root carries a canonical `nano-ack: <path> :: <text>` marker — see
|
|
@@ -8,7 +8,7 @@ import { test } from "node:test";
|
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
9
|
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
10
10
|
import { noopLog } from "../../test/log.ts";
|
|
11
|
-
import handler from "./worker.ts";
|
|
11
|
+
import handler, { makeHandler } from "./worker.ts";
|
|
12
12
|
|
|
13
13
|
function fakeApp() {
|
|
14
14
|
const stores: Record<string, Record<string, unknown>[]> = {
|
|
@@ -294,3 +294,194 @@ test("pr.merge abandons a closed-not-merged PR without escalating (terminal aban
|
|
|
294
294
|
},
|
|
295
295
|
);
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
// ── Merge-loop backstop (issue #923 §3) ──────────────────────────────────────
|
|
299
|
+
// Just before landing, the worker re-checks that a HEAD-CURRENT Copilot review exists. A head that
|
|
300
|
+
// moved between convergence and landing — or a review that never existed (the nano-bpm#1443
|
|
301
|
+
// fail-open: merged with ZERO reviews) — must block the landing, not slip through. These tests
|
|
302
|
+
// drive the backstop through `makeHandler`'s injected readers; the fetch stub only needs the
|
|
303
|
+
// single-PR GET (liveness) since the injected readers replace the review/head fetches.
|
|
304
|
+
|
|
305
|
+
function backstopRoutes(url: string): Response | null {
|
|
306
|
+
if (/\/pulls\/\d+$/.test(url)) {
|
|
307
|
+
return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
|
|
308
|
+
}
|
|
309
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
test("pr.merge #923: refuses to land when NO Copilot review exists (the #1443 fail-open)", async () => {
|
|
313
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
314
|
+
const { app, stores } = fakeApp();
|
|
315
|
+
const backstop = makeHandler({
|
|
316
|
+
// Verified "no Copilot review on this PR" + a readable head → missing → BLOCK.
|
|
317
|
+
readReview: async () => ({ body: "", commitId: null }),
|
|
318
|
+
readHeadSha: async () => "headsha4444444444444444444444444444444444",
|
|
319
|
+
});
|
|
320
|
+
const out = (await backstop(
|
|
321
|
+
{ variables: { prKey: "acme/widgets#21", repo: "acme/widgets", prNumber: 21 } } as any,
|
|
322
|
+
app,
|
|
323
|
+
)) as Record<string, unknown>;
|
|
324
|
+
|
|
325
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
326
|
+
assertEquals(out.status, "blocked");
|
|
327
|
+
assertEquals(typeof out.question, "string");
|
|
328
|
+
assertEquals((out.question as string).includes("no head-current Copilot review"), true);
|
|
329
|
+
|
|
330
|
+
// The refusal is audited, and NO land was attempted (no merge PUT, no enqueue comment).
|
|
331
|
+
assertEquals(stores.merges.length, 1);
|
|
332
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
333
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
334
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
335
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("pr.merge #923: refuses to land when the head MOVED past the latest review (stale at land time)", async () => {
|
|
340
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
341
|
+
const { app, stores } = fakeApp();
|
|
342
|
+
const backstop = makeHandler({
|
|
343
|
+
// The review was submitted against the OLD head; the branch has since advanced.
|
|
344
|
+
readReview: async () => ({ body: "lgtm", commitId: "oldsha1111111111111111111111111111111111" }),
|
|
345
|
+
readHeadSha: async () => "newsha2222222222222222222222222222222222",
|
|
346
|
+
});
|
|
347
|
+
const out = (await backstop(
|
|
348
|
+
{ variables: { prKey: "acme/widgets#23", repo: "acme/widgets", prNumber: 23 } } as any,
|
|
349
|
+
app,
|
|
350
|
+
)) as Record<string, unknown>;
|
|
351
|
+
|
|
352
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
353
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
354
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("pr.merge #923: a HEAD-CURRENT review lands normally (backstop control)", async () => {
|
|
359
|
+
await withGithub(
|
|
360
|
+
(url) => {
|
|
361
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
362
|
+
return backstopRoutes(url);
|
|
363
|
+
},
|
|
364
|
+
async (calls) => {
|
|
365
|
+
const { app, stores } = fakeApp();
|
|
366
|
+
const backstop = makeHandler({
|
|
367
|
+
readReview: async () => ({ body: "lgtm", commitId: "samesha33333333333333333333333333333333" }),
|
|
368
|
+
readHeadSha: async () => "samesha33333333333333333333333333333333",
|
|
369
|
+
});
|
|
370
|
+
const out = (await backstop(
|
|
371
|
+
{ variables: { prKey: "acme/widgets#25", repo: "acme/widgets", prNumber: 25 } } as any,
|
|
372
|
+
app,
|
|
373
|
+
)) as Record<string, unknown>;
|
|
374
|
+
|
|
375
|
+
// The backstop passes and the ordinary land protocol runs (direct merge).
|
|
376
|
+
assertEquals(out, { mergeStatus: "merged" });
|
|
377
|
+
assertEquals(
|
|
378
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
379
|
+
true,
|
|
380
|
+
);
|
|
381
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("pr.merge #923: an UNREADABLE review/head fails OPEN (a transport hiccup never wedges a merge)", async () => {
|
|
387
|
+
await withGithub(
|
|
388
|
+
(url) => {
|
|
389
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
390
|
+
return backstopRoutes(url);
|
|
391
|
+
},
|
|
392
|
+
async (calls) => {
|
|
393
|
+
const { app, stores } = fakeApp();
|
|
394
|
+
const backstop = makeHandler({
|
|
395
|
+
readReview: async () => null, // no usable transport → unverifiable, NOT "no review"
|
|
396
|
+
readHeadSha: async () => {
|
|
397
|
+
throw new Error("boom");
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
const out = (await backstop(
|
|
401
|
+
{ variables: { prKey: "acme/widgets#27", repo: "acme/widgets", prNumber: 27 } } as any,
|
|
402
|
+
app,
|
|
403
|
+
)) as Record<string, unknown>;
|
|
404
|
+
|
|
405
|
+
assertEquals(out, { mergeStatus: "merged" });
|
|
406
|
+
assertEquals(
|
|
407
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
408
|
+
true,
|
|
409
|
+
);
|
|
410
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("pr.merge #923: a head-current review whose summary reports OPEN findings blocks landing", async () => {
|
|
416
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
417
|
+
const { app, stores } = fakeApp();
|
|
418
|
+
const backstop = makeHandler({
|
|
419
|
+
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "h1" }),
|
|
420
|
+
readHeadSha: async () => "h1",
|
|
421
|
+
});
|
|
422
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app)) as Record<string, unknown>;
|
|
423
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
424
|
+
assertEquals((out.question as string).includes("open findings"), true);
|
|
425
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
426
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const [label, readHeadSha] of [
|
|
431
|
+
["returns null", async () => null],
|
|
432
|
+
["throws", async () => { throw new Error("transient"); }],
|
|
433
|
+
] as const) {
|
|
434
|
+
test(`pr.merge #923: an unverifiable head (read ${label}) never blocks on a possibly-stale review's open findings`, async () => {
|
|
435
|
+
await withGithub(backstopRoutes, async () => {
|
|
436
|
+
const { app, stores } = fakeApp();
|
|
437
|
+
const backstop = makeHandler({
|
|
438
|
+
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "old" }),
|
|
439
|
+
readHeadSha,
|
|
440
|
+
});
|
|
441
|
+
await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app);
|
|
442
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
test("pr.merge #923: the direct merge pins the verified head SHA (no check-then-merge race)", async () => {
|
|
448
|
+
let putBody = "";
|
|
449
|
+
await withGithub(
|
|
450
|
+
(url, init) => {
|
|
451
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) {
|
|
452
|
+
putBody = String(init?.body ?? "");
|
|
453
|
+
return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
454
|
+
}
|
|
455
|
+
return backstopRoutes(url);
|
|
456
|
+
},
|
|
457
|
+
async () => {
|
|
458
|
+
const { app } = fakeApp();
|
|
459
|
+
const backstop = makeHandler({ readReview: async () => ({ body: "lgtm", commitId: "h2" }), readHeadSha: async () => "h2" });
|
|
460
|
+
await backstop({ variables: { prKey: "acme/widgets#31", repo: "acme/widgets", prNumber: 31 } } as any, app);
|
|
461
|
+
assertEquals(JSON.parse(putBody).sha, "h2");
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("pr.merge #923: a queue-comment repo whose head moved after the backstop retries instead of enqueueing", async () => {
|
|
467
|
+
const protocol =
|
|
468
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
469
|
+
await withGithub(
|
|
470
|
+
(url) => {
|
|
471
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
472
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
473
|
+
return backstopRoutes(url);
|
|
474
|
+
},
|
|
475
|
+
async (calls) => {
|
|
476
|
+
const { app } = fakeApp();
|
|
477
|
+
const heads = ["h3", "h4"];
|
|
478
|
+
const backstop = makeHandler({
|
|
479
|
+
readReview: async () => ({ body: "lgtm", commitId: "h3" }),
|
|
480
|
+
readHeadSha: async () => heads.shift() ?? "h4",
|
|
481
|
+
});
|
|
482
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#33", repo: "acme/widgets", prNumber: 33 } } as any, app)) as Record<string, unknown>;
|
|
483
|
+
assertEquals(out.mergeStatus, "retry");
|
|
484
|
+
assertEquals(calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"), false);
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
});
|