forgedock 1.17.0 → 1.19.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/bin/engine/outcome-codes.mjs +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +71 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- package/scripts/with-run-tmpdir.sh +20 -0
package/bin/stats/stats.test.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
3
4
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
4
5
|
import assert from "node:assert/strict";
|
|
5
|
-
import {
|
|
6
|
+
import { readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,7 +11,7 @@ import { spawnSync } from "node:child_process";
|
|
|
10
11
|
import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
|
|
11
12
|
import { cacheDir } from "./cache.mjs";
|
|
12
13
|
import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
|
|
13
|
-
import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
|
|
14
|
+
import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
|
|
14
15
|
import { readLocalRuns } from "./local-source.mjs";
|
|
15
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
17
|
|
|
@@ -29,6 +30,10 @@ function fakeGh() {
|
|
|
29
30
|
const path = args.find((a) => a.startsWith("repos/"));
|
|
30
31
|
const m = /issues\/(\d+)\/comments$/.exec(path);
|
|
31
32
|
if (m) return JSON.stringify(comments[m[1]] ?? []);
|
|
33
|
+
if (/issues\/comments\?/.test(path)) { // repo-wide endpoint: every comment, tagged with its issue (fixtures carry no updated_at)
|
|
34
|
+
const all = Object.entries(comments).flatMap(([n, list]) => list.map((c) => ({ ...c, issue_url: `https://api.github.com/repos/o/r/issues/${n}` })));
|
|
35
|
+
return JSON.stringify(/page=1(&|$)/.test(path) ? all : []);
|
|
36
|
+
}
|
|
32
37
|
const since = /since=([^&]+)/.exec(path)?.[1];
|
|
33
38
|
return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
|
|
34
39
|
};
|
|
@@ -36,7 +41,7 @@ function fakeGh() {
|
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
let td;
|
|
39
|
-
beforeEach(() => { td =
|
|
44
|
+
beforeEach(() => { td = tmpDir("stats-"); });
|
|
40
45
|
afterEach(() => rmSync(td, { recursive: true, force: true }));
|
|
41
46
|
|
|
42
47
|
describe("telemetry comment parsing", () => {
|
|
@@ -58,9 +63,9 @@ describe("telemetry comment parsing", () => {
|
|
|
58
63
|
});
|
|
59
64
|
|
|
60
65
|
describe("GitHub-only report (fresh machine)", () => {
|
|
61
|
-
it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
|
|
66
|
+
it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", async () => {
|
|
62
67
|
const { gh } = fakeGh();
|
|
63
|
-
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
68
|
+
const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
64
69
|
const g = report.groups.find((x) => x.version === "1.17.0");
|
|
65
70
|
assert.equal(g.n, 1);
|
|
66
71
|
assert.equal(g.cost.cacheReadMean, 3000000);
|
|
@@ -85,6 +90,8 @@ describe("GitHub-only report (fresh machine)", () => {
|
|
|
85
90
|
assert.equal(u.funnel.falsePositive, 1);
|
|
86
91
|
assert.equal(u.issueFlow.net, 2);
|
|
87
92
|
assert.equal(u.issueFlow.findingsPerMerge, null);
|
|
93
|
+
assert.equal(g.funnel.introduced, 2, "only events carrying scope=introduced are counted");
|
|
94
|
+
assert.equal(g.issueFlow.introducedPerMerge, (g.issueFlow.closed.merged ?? 0) > 0 ? 2 / g.issueFlow.closed.merged : null);
|
|
88
95
|
assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
|
|
89
96
|
});
|
|
90
97
|
it("matches the run-record field names written by the engine seam", () => {
|
|
@@ -93,6 +100,27 @@ describe("GitHub-only report (fresh machine)", () => {
|
|
|
93
100
|
assert.equal(runs[0].finished, true);
|
|
94
101
|
assert.equal(runs[0].phases.review.cr, 500000);
|
|
95
102
|
});
|
|
103
|
+
const rec = (run, ts, event, x = {}) => ({ event, repo: "o/r", issue: 500, run, ts, eid: run + ts + event, ...x });
|
|
104
|
+
it("collapses a retried issue (two run ids) to the latest run", () => {
|
|
105
|
+
const runs = runsFromRecords([
|
|
106
|
+
rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
|
|
107
|
+
rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "engine-error" }),
|
|
108
|
+
rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
|
|
109
|
+
rec("r_500_2", "2026-01-02T00:01:00Z", "run_terminal", { reason: "merged" }),
|
|
110
|
+
]);
|
|
111
|
+
assert.equal(runs.length, 1);
|
|
112
|
+
assert.equal(runs[0].reason, "merged");
|
|
113
|
+
assert.equal(runs[0].phases.build.cr, 1000000);
|
|
114
|
+
});
|
|
115
|
+
it("lets an unfinished latest run win over an earlier finished retried run", () => {
|
|
116
|
+
const runs = runsFromRecords([
|
|
117
|
+
rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
|
|
118
|
+
rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "merged" }),
|
|
119
|
+
rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
|
|
120
|
+
]);
|
|
121
|
+
assert.equal(runs.length, 1);
|
|
122
|
+
assert.equal(runs[0].finished, false);
|
|
123
|
+
});
|
|
96
124
|
});
|
|
97
125
|
|
|
98
126
|
describe("local runs, joins, version delta and low-n", () => {
|
|
@@ -117,6 +145,15 @@ describe("local runs, joins, version delta and low-n", () => {
|
|
|
117
145
|
assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
|
|
118
146
|
assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
|
|
119
147
|
});
|
|
148
|
+
it("reports introduced findings per merged issue", () => {
|
|
149
|
+
const ev = (eid, scope) => ({ event: "finding_disposition", eid, repo: "o/r", pr: 1, issue: eid === "d" ? 2 : 1, version: "1.18.0", severity: "MEDIUM", disposition: "INPR_FIX", ...(scope ? { scope } : {}) });
|
|
150
|
+
const issue = (number) => ({ number, repo: "o/r", isPr: false, labels: ["workflow:merged"], createdAt: "2026-10-06T00:00:00Z", closedAt: "2026-10-07T00:00:00Z" });
|
|
151
|
+
const r = aggregate({ events: [ev("a", "introduced"), ev("b", "introduced"), ev("c", "pre-existing"), ev("d")], issues: [issue(1), issue(2)] }, { releaseTimes: [] });
|
|
152
|
+
const g = r.groups.find((x) => x.version === "1.18.0");
|
|
153
|
+
assert.equal(g.funnel.introduced, 2);
|
|
154
|
+
assert.equal(g.issueFlow.introducedPerMerge, 1);
|
|
155
|
+
assert.match(renderMarkdown(r), /introduced findings per merged issue/);
|
|
156
|
+
});
|
|
120
157
|
it("computes the delta against the previous version of the same repo", () => {
|
|
121
158
|
const g = agg().groups.find((x) => x.version === "1.17.0");
|
|
122
159
|
assert.equal(g.delta.against, "1.16.0");
|
|
@@ -157,9 +194,9 @@ describe("n/a degradation and rendering", () => {
|
|
|
157
194
|
assert.match(renderMarkdown(aggregate({})), /No data/);
|
|
158
195
|
assert.equal(percentile([], 90), null);
|
|
159
196
|
});
|
|
160
|
-
it("json output is parseable and carries every section", () => {
|
|
197
|
+
it("json output is parseable and carries every section", async () => {
|
|
161
198
|
const { gh } = fakeGh();
|
|
162
|
-
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
199
|
+
const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
163
200
|
const parsed = JSON.parse(renderJson(report));
|
|
164
201
|
for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
|
|
165
202
|
const md = renderMarkdown(report);
|
|
@@ -172,32 +209,218 @@ describe("n/a degradation and rendering", () => {
|
|
|
172
209
|
});
|
|
173
210
|
|
|
174
211
|
describe("cache", () => {
|
|
175
|
-
it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
|
|
212
|
+
it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", async () => {
|
|
176
213
|
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
|
|
177
214
|
const a = fakeGh();
|
|
178
|
-
const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
|
|
215
|
+
const first = await fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
|
|
179
216
|
assert.equal(first.fromCache, false);
|
|
180
217
|
assert.ok(!a.calls[0].includes("since="));
|
|
181
218
|
const b = fakeGh();
|
|
182
|
-
const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
219
|
+
const second = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
183
220
|
assert.equal(second.fromCache, true);
|
|
184
|
-
assert.match(b.calls[0], /since=2026-10-
|
|
221
|
+
assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
|
|
185
222
|
assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
|
|
186
223
|
// corrupt cache -> treated as empty -> same result from a full fetch
|
|
187
224
|
writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
|
|
188
225
|
const c = fakeGh();
|
|
189
|
-
const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
|
|
226
|
+
const third = await fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
|
|
190
227
|
assert.equal(third.fromCache, false);
|
|
191
228
|
assert.equal(third.records.length, first.records.length);
|
|
192
229
|
// --refresh ignores a good cache
|
|
193
230
|
const d = fakeGh();
|
|
194
|
-
fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
|
|
231
|
+
await fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
|
|
195
232
|
assert.ok(!d.calls[0].includes("since="));
|
|
196
233
|
assert.equal(toModel("o/r", third).issues.length, 5);
|
|
197
234
|
});
|
|
198
|
-
|
|
235
|
+
// Page-aware fake of the issues API over a synthetic store; the repo-wide comments endpoint is empty unless `comments` is given.
|
|
236
|
+
const storeGh = (store, { comments = [], failOnListCall = 0 } = {}) => {
|
|
237
|
+
const calls = [];
|
|
238
|
+
let listCalls = 0;
|
|
239
|
+
const gh = (args) => {
|
|
240
|
+
const path = args.find((a) => a.startsWith("repos/"));
|
|
241
|
+
calls.push(path);
|
|
242
|
+
const q = new URLSearchParams(path.split("?")[1] ?? "");
|
|
243
|
+
if (/issues\/comments\?/.test(path)) {
|
|
244
|
+
const since = q.get("since"), page = Number(q.get("page") ?? 1);
|
|
245
|
+
return JSON.stringify(comments.filter((c) => !since || c.updated_at >= since).slice((page - 1) * 100, page * 100));
|
|
246
|
+
}
|
|
247
|
+
const m = /issues\/(\d+)\/comments$/.exec(path);
|
|
248
|
+
if (m) return JSON.stringify(comments.filter((c) => c.issue_url.endsWith(`/issues/${m[1]}`)));
|
|
249
|
+
const one = /issues\/(\d+)$/.exec(path);
|
|
250
|
+
if (one) return JSON.stringify(store.find((i) => i.number === Number(one[1])));
|
|
251
|
+
if (++listCalls === failOnListCall) throw new Error("rate limited");
|
|
252
|
+
const since = q.get("since");
|
|
253
|
+
const sorted = store.filter((i) => !since || i.updated_at >= since).sort((x, y) => x.updated_at.localeCompare(y.updated_at) || x.number - y.number);
|
|
254
|
+
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
255
|
+
return JSON.stringify(sorted.slice((page - 1) * per, page * per));
|
|
256
|
+
};
|
|
257
|
+
return { gh, calls };
|
|
258
|
+
};
|
|
259
|
+
const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 3600000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
260
|
+
const mkItems = (n) => Array.from({ length: n }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
|
|
261
|
+
const telemetryComment = (issue, eid, event, updated, extra = {}) => ({ id: issue * 1000 + Number(eid.replace(/\D/g, "")), updated_at: updated, user: { type: "Bot" },
|
|
262
|
+
issue_url: `https://api.github.com/repos/o/r/issues/${issue}`,
|
|
263
|
+
body: `<!-- FORGE:TELEMETRY v1 -->\n\`\`\`jsonl\n${JSON.stringify({ v: 1, ts: updated, forge_version: "1.18.0", repo: "o/r", issue, run: `r_${issue}`, eid, event, phase: "build", cache_read_tokens: 100, ...extra })}\n\`\`\`` });
|
|
264
|
+
|
|
265
|
+
it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", async () => {
|
|
266
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
|
|
267
|
+
const store = mkItems(250);
|
|
268
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { failOnListCall: 3 }).gh, env }), /rate limited/);
|
|
269
|
+
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
270
|
+
assert.ok(saved.watermark, "partial watermark persisted");
|
|
271
|
+
assert.ok(Object.keys(saved.items).length >= 100 && Object.keys(saved.items).length < 250);
|
|
272
|
+
const b = storeGh(store);
|
|
273
|
+
const resumed = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
274
|
+
assert.equal(resumed.fromCache, true);
|
|
275
|
+
assert.ok(b.calls[0].includes("since="), "resumes from the watermark");
|
|
276
|
+
assert.equal(resumed.items.length, 250);
|
|
277
|
+
});
|
|
278
|
+
it("a mid-crawl failure never advances the watermark past unpersisted telemetry (resume still finds it)", async () => {
|
|
279
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-pending-telemetry") };
|
|
280
|
+
const store = mkItems(250);
|
|
281
|
+
// A telemetry comment on the NEWEST issue (listed last), posted before the failing run starts.
|
|
282
|
+
const comments = [telemetryComment(250, "z1", "phase_attempt", ts(250))];
|
|
283
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments, failOnListCall: 3 }).gh, env }), /rate limited/);
|
|
284
|
+
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
285
|
+
assert.ok(!saved.watermark, "watermark must not be persisted while telemetry records are unpersisted");
|
|
286
|
+
// Resume: the comment is older than any watermark a buggy checkpoint would have saved.
|
|
287
|
+
const resumed = await fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments }).gh, env });
|
|
288
|
+
assert.equal(resumed.items.length, 250);
|
|
289
|
+
assert.deepEqual(resumed.records.map((r) => r.eid), ["z1"]);
|
|
290
|
+
assert.equal(resumed.items.find((i) => i.number === 250).records.length, 1);
|
|
291
|
+
});
|
|
292
|
+
it("bounds the crawl by the window: API calls track the window, not the repo size", async () => {
|
|
293
|
+
const store = mkItems(3000); // 3000 items, one per hour
|
|
294
|
+
const from = ts(2990);
|
|
295
|
+
const windowItems = store.filter((i) => i.updated_at >= from).length;
|
|
296
|
+
const comments = store.slice(-5).map((i) => telemetryComment(i.number, `x${i.number}`, "phase_attempt", i.updated_at));
|
|
297
|
+
const { gh, calls } = storeGh(store, { comments });
|
|
298
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: from, env: ENV });
|
|
299
|
+
assert.equal(r.items.length, windowItems);
|
|
300
|
+
// 1 comments page + one refetch per telemetry issue + ceil(window / 100) list pages; never one call per repo item.
|
|
301
|
+
const bound = 1 + comments.length + Math.ceil(windowItems / 100) + 1;
|
|
302
|
+
assert.ok(r.apiCalls <= bound, `api calls ${r.apiCalls} exceed window bound ${bound}`);
|
|
303
|
+
assert.equal(calls.length, r.apiCalls);
|
|
304
|
+
assert.ok(calls.length < 30 && store.length > 100 * calls.length, `calls.length ${calls.length} must be far below the repo size`);
|
|
305
|
+
assert.ok(calls.some((c) => /issues\/comments\?.*since=/.test(c)), "comments come from the repo-wide since= endpoint");
|
|
306
|
+
assert.ok(!calls.some((c) => /issues\/\d+\/comments$/.test(c) && !comments.some((x) => x.issue_url.endsWith(c.match(/issues\/(\d+)\//)[1]))), "no comment call for items without telemetry");
|
|
307
|
+
assert.equal(r.records.length, comments.length);
|
|
308
|
+
});
|
|
309
|
+
it("re-reads every part of an issue whose telemetry was touched in the window, and only those", async () => {
|
|
310
|
+
const store = mkItems(20);
|
|
311
|
+
const comments = [telemetryComment(7, "e1", "phase_attempt", ts(1)), telemetryComment(7, "e2", "run_terminal", ts(15), { reason: "merged" }),
|
|
312
|
+
telemetryComment(9, "e1", "phase_attempt", ts(2))];
|
|
313
|
+
const { gh, calls } = storeGh(store, { comments });
|
|
314
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: ts(10), env: ENV });
|
|
315
|
+
assert.deepEqual(r.records.filter((x) => x.issue === 7).map((x) => x.event).sort(), ["phase_attempt", "run_terminal"], "the pre-window part is fetched too");
|
|
316
|
+
assert.equal(calls.filter((c) => /issues\/7\/comments$/.test(c)).length, 1);
|
|
317
|
+
assert.equal(calls.filter((c) => /issues\/9\/comments$/.test(c)).length, 0, "issue 9 has no telemetry in the window");
|
|
318
|
+
});
|
|
319
|
+
it("accepts an async gh and awaits it", async () => {
|
|
320
|
+
const store = mkItems(5);
|
|
321
|
+
const { gh } = storeGh(store);
|
|
322
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh: async (a) => gh(a), useCache: false, env: ENV });
|
|
323
|
+
assert.equal(r.items.length, 5);
|
|
324
|
+
});
|
|
325
|
+
it("records the coverage floor, crawls only the gap for a wider window and resets on --refresh", async () => {
|
|
326
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-floor") };
|
|
327
|
+
const store = mkItems(100);
|
|
328
|
+
const w1 = storeGh(store);
|
|
329
|
+
const narrow = await fetchGithubRecords({ repo: "o/r", gh: w1.gh, env, since: ts(90) });
|
|
330
|
+
assert.equal(narrow.items.length, 11);
|
|
331
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
|
|
332
|
+
// same or narrower window: incremental from the watermark, floor unchanged
|
|
333
|
+
const same = storeGh(store);
|
|
334
|
+
await fetchGithubRecords({ repo: "o/r", gh: same.gh, env, since: ts(95) });
|
|
335
|
+
assert.ok(/since=/.test(same.calls[0]) && !same.calls[0].includes(`since=${ts(95)}`), "resumes from the watermark, not the window start");
|
|
336
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
|
|
337
|
+
// wider window: the uncovered gap is crawled (from the new start), then the floor is lowered
|
|
338
|
+
const wide = storeGh(store);
|
|
339
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh: wide.gh, env, since: ts(50) });
|
|
340
|
+
assert.ok(wide.calls[0].includes(`since=${ts(50)}`));
|
|
341
|
+
assert.equal(r.items.length, 51, "the pre-window gap is not silently treated as covered");
|
|
342
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(50));
|
|
343
|
+
// no --since after a windowed run: full history is crawled and the floor becomes null
|
|
344
|
+
const all = storeGh(store);
|
|
345
|
+
assert.equal((await fetchGithubRecords({ repo: "o/r", gh: all.gh, env })).items.length, 100);
|
|
346
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, null);
|
|
347
|
+
// --refresh ignores the cache and its floor
|
|
348
|
+
const fresh = storeGh(store);
|
|
349
|
+
await fetchGithubRecords({ repo: "o/r", gh: fresh.gh, env, refresh: true, since: ts(80) });
|
|
350
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(80));
|
|
351
|
+
});
|
|
352
|
+
it("does not trust a legacy cache (no coveredFrom) for a window and never lowers the floor on failure", async () => {
|
|
353
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-legacy") };
|
|
354
|
+
const store = mkItems(250);
|
|
355
|
+
mkdirSync(cacheDir("o/r", env), { recursive: true });
|
|
356
|
+
writeFileSync(join(cacheDir("o/r", env), "cache.json"), JSON.stringify({ v: 1, watermark: ts(250), items: {} }));
|
|
357
|
+
const g = storeGh(store, { failOnListCall: 2 });
|
|
358
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: g.gh, env, since: ts(10) }), /rate limited/);
|
|
359
|
+
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
360
|
+
assert.equal(saved.watermark, ts(250), "failed gap crawl keeps the old watermark");
|
|
361
|
+
assert.ok(!("coveredFrom" in saved) || saved.coveredFrom == null || saved.coveredFrom === undefined);
|
|
362
|
+
const h = storeGh(store);
|
|
363
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh: h.gh, env, since: ts(10) });
|
|
364
|
+
assert.ok(h.calls[0].includes(`since=${ts(10)}`));
|
|
365
|
+
assert.equal(r.items.length, 241);
|
|
366
|
+
});
|
|
367
|
+
it("reports progress through onProgress and backs off when the rate limit is low", async () => {
|
|
368
|
+
const store = mkItems(6000);
|
|
369
|
+
const events = [];
|
|
370
|
+
await fetchGithubRecords({ repo: "o/r", gh: storeGh(store).gh, useCache: false, env: ENV, onProgress: (e) => events.push(e) });
|
|
371
|
+
assert.ok(events.some((e) => e.phase === "comments") && events.some((e) => e.phase === "issues" && e.done === 6000));
|
|
372
|
+
const slept = [];
|
|
373
|
+
const base = storeGh(store).gh;
|
|
374
|
+
const gh = (args) => (args.join(" ") === "api rate_limit" ? JSON.stringify({ resources: { core: { remaining: 3, reset: 1_000_100 } } }) : base(args));
|
|
375
|
+
await fetchGithubRecords({ repo: "o/r", gh, useCache: false, env: ENV, rateCheck: true, now: () => 1_000_000_000, sleep: async (ms) => { slept.push(ms); } });
|
|
376
|
+
assert.ok(slept.length > 0 && slept[0] === 101000, `slept ${slept}`);
|
|
377
|
+
});
|
|
378
|
+
it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", async () => {
|
|
379
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-midcrawl") };
|
|
380
|
+
const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
381
|
+
const store = Array.from({ length: 250 }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
|
|
382
|
+
let listCalls = 0;
|
|
383
|
+
let maxUpdated = 250;
|
|
384
|
+
// Page-aware fake of the real API: asc by updated_at, honours since (inclusive), per_page and page. After the first
|
|
385
|
+
// listing call an already-served item is bumped, which moves it to the end of the ordering.
|
|
386
|
+
const gh = (args) => {
|
|
387
|
+
const path = args.find((a) => a.startsWith("repos/"));
|
|
388
|
+
if (/issues\/comments\?/.test(path)) return "[]";
|
|
389
|
+
const q = new URLSearchParams(path.split("?")[1]);
|
|
390
|
+
listCalls++;
|
|
391
|
+
const since = q.get("since");
|
|
392
|
+
const sorted = store.filter((i) => !since || i.updated_at >= since).sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.number - b.number);
|
|
393
|
+
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
394
|
+
const out = JSON.stringify(sorted.slice((page - 1) * per, page * per));
|
|
395
|
+
if (listCalls === 1) store[4].updated_at = ts(++maxUpdated);
|
|
396
|
+
return out;
|
|
397
|
+
};
|
|
398
|
+
const first = await fetchGithubRecords({ repo: "o/r", gh, env });
|
|
399
|
+
assert.equal(first.items.length, 250, "every item is cached after the crawl, none skipped by the shift");
|
|
400
|
+
assert.equal(new Set(first.items.map((i) => i.number)).size, 250);
|
|
401
|
+
// The bumped item moved past the crawl cursor, so the next incremental run (and its overlap window) still sees it.
|
|
402
|
+
store[9].updated_at = ts(++maxUpdated); // another item updated after the first run
|
|
403
|
+
const second = await fetchGithubRecords({ repo: "o/r", gh, env });
|
|
404
|
+
assert.equal(second.fromCache, true);
|
|
405
|
+
assert.equal(second.items.length, 250);
|
|
406
|
+
assert.equal(second.items.find((i) => i.number === 10).updatedAt, store[9].updated_at);
|
|
407
|
+
assert.equal(second.items.find((i) => i.number === 5).updatedAt, store[4].updated_at);
|
|
408
|
+
});
|
|
409
|
+
it("terminates and drains a full page whose items all share one updated_at", async () => {
|
|
410
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-sameTs") };
|
|
411
|
+
const store = Array.from({ length: 230 }, (_, i) => ({ number: i + 1, updated_at: "2026-10-02T00:00:00Z", created_at: "2026-10-01T00:00:00Z", comments: 0, labels: [], user: { type: "Bot" } }));
|
|
412
|
+
const gh = (args) => {
|
|
413
|
+
if (/issues\/comments\?/.test(args.find((a) => a.startsWith("repos/")))) return "[]";
|
|
414
|
+
const q = new URLSearchParams(args.find((a) => a.startsWith("repos/")).split("?")[1]);
|
|
415
|
+
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
416
|
+
const since = q.get("since");
|
|
417
|
+
return JSON.stringify(store.filter((i) => !since || i.updated_at >= since).slice((page - 1) * per, page * per));
|
|
418
|
+
};
|
|
419
|
+
assert.equal((await fetchGithubRecords({ repo: "o/r", gh, env })).items.length, 230);
|
|
420
|
+
});
|
|
421
|
+
it("rejects an unsafe repo before any gh call", async () => {
|
|
199
422
|
const { gh, calls } = fakeGh();
|
|
200
|
-
assert.
|
|
423
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
|
|
201
424
|
assert.equal(calls.length, 0);
|
|
202
425
|
});
|
|
203
426
|
});
|
|
@@ -210,6 +433,32 @@ describe("cli", () => {
|
|
|
210
433
|
assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
|
|
211
434
|
assert.throws(() => parseSince("yesterday"), /--since must be/);
|
|
212
435
|
});
|
|
436
|
+
it("resolves --since to a crawl lower bound (date as is, version via release time minus a margin)", () => {
|
|
437
|
+
assert.equal(sinceToTimestamp("2026-10-01").since, "2026-10-01T00:00:00Z");
|
|
438
|
+
assert.equal(sinceToTimestamp(undefined).since, null);
|
|
439
|
+
const v = sinceToTimestamp("v1.16.0", [{ v: "1.16.0", t: Date.parse("2026-10-05T00:00:00Z") / 1000 }]);
|
|
440
|
+
assert.equal(v.since, "2026-10-04T00:00:00Z");
|
|
441
|
+
const unknown = sinceToTimestamp("1.99.0", []);
|
|
442
|
+
assert.equal(unknown.since, null);
|
|
443
|
+
assert.match(unknown.note, /full history/);
|
|
444
|
+
});
|
|
445
|
+
it("prints progress to stderr only, throttled, and keeps --json stdout parseable", async () => {
|
|
446
|
+
const out = [];
|
|
447
|
+
let t = 0;
|
|
448
|
+
const print = makeProgressPrinter((x) => out.push(x), () => t);
|
|
449
|
+
print({ phase: "issues", done: 100, total: null });
|
|
450
|
+
t += 500; print({ phase: "issues", done: 200, total: null });
|
|
451
|
+
t += 2000; print({ phase: "issues", done: 300, total: null });
|
|
452
|
+
assert.equal(out.length, 2);
|
|
453
|
+
const { gh } = fakeGh();
|
|
454
|
+
const realOut = process.stdout.write, realErr = process.stderr.write;
|
|
455
|
+
let stdout = "", stderr = "";
|
|
456
|
+
process.stdout.write = (c) => { stdout += c; return true; };
|
|
457
|
+
process.stderr.write = (c) => { stderr += c; return true; };
|
|
458
|
+
try { assert.equal(await runStats(["--repo", "o/r", "--json", "--no-cache", "--no-local"], { gh, env: ENV, releaseTimes: [] }), 0); } finally { process.stdout.write = realOut; process.stderr.write = realErr; }
|
|
459
|
+
assert.ok(JSON.parse(stdout).groups, "stdout is only the JSON report");
|
|
460
|
+
assert.match(stderr, /forgedock stats: (comments|issues) /);
|
|
461
|
+
});
|
|
213
462
|
it("--help exits 0 and importing the module did not run it", () => {
|
|
214
463
|
const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
|
|
215
464
|
assert.equal(r.status, 0);
|
package/commands/cleanup.md
CHANGED
|
@@ -241,6 +241,7 @@ Each `/orchestrate` batch creates one claims board (`orchestrate: claims board f
|
|
|
241
241
|
A board is closed only when **all** identity checks pass and **one** closing condition holds:
|
|
242
242
|
|
|
243
243
|
- Identity: title starts with `orchestrate: claims board for batch`, body contains `<!-- FORGE:COORD_ISSUE -->`, and the author is trusted: `author_association` `OWNER`, `MEMBER` or `COLLABORATOR`, or a Bot account (the pipeline's GitHub App, reported as `CONTRIBUTOR`). A look-alike issue from an outside account is never closed.
|
|
244
|
+
- Lease/claim comments count only when trusted (same `author_association` `OWNER`/`MEMBER`/`COLLABORATOR` or Bot predicate as the board, mirroring `scripts/trusted-comments.sh`) and when the marker starts the comment body; an untrusted or quoted marker is ignored, so a board falls back to its trusted events or its creation time. Unlike `/orchestrate`'s no-authorship lease check (forge#2644, a bounded self-expiring dispatch refusal), cleanup's action is a destructive issue close, so it must not trust arbitrary commenters.
|
|
244
245
|
- Close when the last `FORGE:LEASE*` comment is a `FORGE:LEASE_RELEASED`.
|
|
245
246
|
- Close when the last `FORGE:LEASE*`/`FORGE:CLAIM*` comment is older than `BOARD_STALE_HOURS` (default 6, far above the 15 minute lease TTL).
|
|
246
247
|
- Close when there is no lease/claim comment and the issue itself is older than `BOARD_STALE_HOURS`.
|
|
@@ -272,11 +273,17 @@ while IFS=$'\t' read -r BNUM BCREATED; do
|
|
|
272
273
|
|
|
273
274
|
# --paginate emits one array per page: slurp (jq -s) and flatten BEFORE taking `last`,
|
|
274
275
|
# otherwise a board with >30 comments reads a stale event from an earlier page.
|
|
276
|
+
# Lease/claim comments are trust-filtered with the same predicate as the board (OWNER/MEMBER/
|
|
277
|
+
# COLLABORATOR or Bot; mirrors scripts/trusted-comments.sh) and the marker must LEAD the body, so an
|
|
278
|
+
# outside commenter can neither post a fake FORGE:LEASE_RELEASED nor a fake fresh FORGE:LEASE/CLAIM,
|
|
279
|
+
# and a quoted marker in prose never matches.
|
|
275
280
|
LAST_LEASE=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
|
|
276
|
-
| jq -s '[.[][] | select(.
|
|
277
|
-
|
|
281
|
+
| jq -s '[.[][] | select((((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot")))
|
|
282
|
+
| select((.body // "") | test("\\A\\s*<!-- FORGE:LEASE"))] | last // empty
|
|
283
|
+
| {released: (.body | test("\\A\\s*<!-- FORGE:LEASE_RELEASED")), at: .created_at}' 2>/dev/null || echo "")
|
|
278
284
|
LAST_ACTIVITY=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
|
|
279
|
-
| jq -rs '[.[][] | select(.
|
|
285
|
+
| jq -rs '[.[][] | select((((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot")))
|
|
286
|
+
| select((.body // "") | test("\\A\\s*<!-- FORGE:(LEASE|CLAIM)"))] | last // empty | .created_at' 2>/dev/null || echo "")
|
|
280
287
|
|
|
281
288
|
REASON=""
|
|
282
289
|
if [ -n "$LAST_LEASE" ] && [ "$(echo "$LAST_LEASE" | jq -r '.released')" = "true" ]; then
|
package/commands/deploy-pr.md
CHANGED
|
@@ -183,6 +183,70 @@ else
|
|
|
183
183
|
fi
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
+
### 2A.5: Version Sync Pre-flight
|
|
187
|
+
|
|
188
|
+
Applies ONLY when `SOURCE=$STAGING_BRANCH` and `PR_TARGET=$DEFAULT_BRANCH` (a staging deploy). Skip for every other lane. Publishing bumps the version on the default branch only, so the deploy PR fails the Deploy Version Guard until staging carries that version. The publish workflow normally syncs it; this pre-flight is the safety net when that sync failed. <!-- Added: forge#3695 -->
|
|
189
|
+
|
|
190
|
+
Rule (same as the publish workflow): sync only when the default branch `package.json` version is strictly ahead of the source branch's. If the source is already at or above it, skip. The sync is a version-only commit (`package.json` and `.claude-plugin/plugin.json`), never a merge of the default branch.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
|
|
194
|
+
STAGING_BRANCH=$(yq '.branches.staging // "staging"' "$CONFIG_FILE" 2>/dev/null || echo "staging")
|
|
195
|
+
DEFAULT_BRANCH=$(yq '.branches.default // "main"' "$CONFIG_FILE" 2>/dev/null || echo "main")
|
|
196
|
+
REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE" 2>/dev/null || echo "$(git rev-parse --show-toplevel)")
|
|
197
|
+
|
|
198
|
+
if [ "$SOURCE" = "$STAGING_BRANCH" ] && [ "$PR_TARGET" = "$DEFAULT_BRANCH" ]; then
|
|
199
|
+
cd "$REPO_PATH"
|
|
200
|
+
git fetch origin "$SOURCE" "$DEFAULT_BRANCH" || { echo "version sync: fetch failed — continuing (version guard will report)"; }
|
|
201
|
+
MAIN_VERSION=$(git show "origin/$DEFAULT_BRANCH:package.json" | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0, 'utf8')).version)")
|
|
202
|
+
SRC_VERSION=$(git show "origin/$SOURCE:package.json" | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0, 'utf8')).version)")
|
|
203
|
+
|
|
204
|
+
# Numeric semver compare in node (never a string compare: 1.10.0 > 1.9.0).
|
|
205
|
+
if MAIN_VERSION="$MAIN_VERSION" SRC_VERSION="$SRC_VERSION" node -e "
|
|
206
|
+
const p = (v) => v.split('.').map(Number);
|
|
207
|
+
const [m, s] = [p(process.env.MAIN_VERSION), p(process.env.SRC_VERSION)];
|
|
208
|
+
for (let i = 0; i < 3; i++) { if (m[i] !== s[i]) process.exit(m[i] > s[i] ? 0 : 1); }
|
|
209
|
+
process.exit(1);
|
|
210
|
+
"; then
|
|
211
|
+
echo "$DEFAULT_BRANCH is at $MAIN_VERSION, ahead of $SOURCE ($SRC_VERSION) — version sync needed"
|
|
212
|
+
if [ "${DRY_RUN:-false}" = "true" ]; then
|
|
213
|
+
echo "[dry-run] would sync version $MAIN_VERSION to $SOURCE"
|
|
214
|
+
else
|
|
215
|
+
SYNC_DIR=$(mktemp -d)
|
|
216
|
+
git worktree add --detach "$SYNC_DIR" "origin/$SOURCE"
|
|
217
|
+
(
|
|
218
|
+
cd "$SYNC_DIR" || exit 1
|
|
219
|
+
VERSION="$MAIN_VERSION" node -e "
|
|
220
|
+
const fs = require('fs');
|
|
221
|
+
for (const f of ['package.json', '.claude-plugin/plugin.json']) {
|
|
222
|
+
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
223
|
+
j.version = process.env.VERSION;
|
|
224
|
+
fs.writeFileSync(f, JSON.stringify(j, null, 2) + '\\n');
|
|
225
|
+
}
|
|
226
|
+
"
|
|
227
|
+
git add package.json .claude-plugin/plugin.json
|
|
228
|
+
git commit -m "chore: sync version $MAIN_VERSION to $SOURCE"
|
|
229
|
+
if ! git push origin "HEAD:$SOURCE"; then
|
|
230
|
+
# Push rejected (protected branch or race): fall back to a sync PR.
|
|
231
|
+
SYNC_BRANCH="chore/sync-main-into-staging-$MAIN_VERSION"
|
|
232
|
+
git push -f origin "HEAD:refs/heads/$SYNC_BRANCH" \
|
|
233
|
+
&& SYNC_PR=$(gh pr create $GH_FLAG --base "$SOURCE" --head "$SYNC_BRANCH" \
|
|
234
|
+
--title "chore: sync version $MAIN_VERSION to $SOURCE" \
|
|
235
|
+
--body "Version-only sync so the deploy PR passes the Deploy Version Guard. Generated by deploy-pr 2A.5.") \
|
|
236
|
+
&& echo "Opened sync PR $SYNC_PR — wait for CI green and merge (never bypass gating), then re-run"
|
|
237
|
+
fi
|
|
238
|
+
)
|
|
239
|
+
git worktree remove --force "$SYNC_DIR" 2>/dev/null || true
|
|
240
|
+
git fetch origin "$SOURCE" 2>/dev/null || true # re-fetch before 2B/2C
|
|
241
|
+
fi
|
|
242
|
+
else
|
|
243
|
+
echo "$SOURCE ($SRC_VERSION) is at or above $DEFAULT_BRANCH ($MAIN_VERSION) — no version sync needed"
|
|
244
|
+
fi
|
|
245
|
+
fi
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
If a sync PR was opened instead of a direct push, wait for it to pass CI and merge through the normal gates before continuing to 2B; do not create or update the deploy PR against a stale source version. Phase 2B onward and the `PR_NUMBER` handoff are unchanged.
|
|
249
|
+
|
|
186
250
|
### 2B: Gather Commit Summary (for new PR body)
|
|
187
251
|
|
|
188
252
|
Skip if PR already exists.
|
package/commands/fix-ci.md
CHANGED
|
@@ -312,11 +312,18 @@ echo "Failure detail: ${FAILURE_DETAIL}"
|
|
|
312
312
|
|
|
313
313
|
### Phase 1D: Apply targeted fix
|
|
314
314
|
|
|
315
|
-
Work on the PR's head
|
|
315
|
+
Work on the PR's head in a dedicated detached worktree, never in the main checkout (it is shared with the operator and other agents). The fix is pushed to the existing PR head with `HEAD:refs/heads/...`. Create the worktree once (reuse it on later attempts) and run every fix, format, test, commit and push step from inside it (`cd "$FIX_WT"`); Phase 2A removes it on every exit path:
|
|
316
316
|
|
|
317
317
|
```bash
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
if [ -z "${FIX_WT:-}" ] || [ ! -d "$FIX_WT" ]; then
|
|
319
|
+
FIX_WT=$(mktemp -d)
|
|
320
|
+
git -C "$REPO_PATH" fetch origin "$FIX_TARGET" \
|
|
321
|
+
&& git -C "$REPO_PATH" worktree add --detach "$FIX_WT" "origin/${FIX_TARGET}" \
|
|
322
|
+
|| { echo "ERROR: could not create fix worktree"; rmdir "$FIX_WT" 2>/dev/null; LOOP_STATUS="blocked"; break; }
|
|
323
|
+
fi
|
|
324
|
+
git -C "$FIX_WT" fetch origin "$FIX_TARGET" 2>/dev/null || true
|
|
325
|
+
git -C "$FIX_WT" rebase "origin/${FIX_TARGET}" 2>/dev/null || true
|
|
326
|
+
cd "$FIX_WT"
|
|
320
327
|
```
|
|
321
328
|
|
|
322
329
|
**Apply fix based on failure class**:
|
|
@@ -440,13 +447,14 @@ echo "Committed fix for attempt ${ATTEMPT}"
|
|
|
440
447
|
**Push** (normal push only — no force):
|
|
441
448
|
|
|
442
449
|
```bash
|
|
443
|
-
git push origin "
|
|
450
|
+
git -C "$FIX_WT" push origin HEAD:"refs/heads/${FIX_TARGET}" 2>&1
|
|
444
451
|
PUSH_EXIT=$?
|
|
445
452
|
|
|
446
453
|
if [ "$PUSH_EXIT" -ne 0 ]; then
|
|
447
454
|
echo "Push failed (exit ${PUSH_EXIT}) — attempting pull-rebase then re-push..."
|
|
448
|
-
git
|
|
449
|
-
git
|
|
455
|
+
git -C "$FIX_WT" fetch origin "$FIX_TARGET" 2>&1 && \
|
|
456
|
+
git -C "$FIX_WT" rebase "origin/${FIX_TARGET}" 2>&1 && \
|
|
457
|
+
git -C "$FIX_WT" push origin HEAD:"refs/heads/${FIX_TARGET}" 2>&1
|
|
450
458
|
PUSH_EXIT=$?
|
|
451
459
|
fi
|
|
452
460
|
|
|
@@ -476,6 +484,12 @@ After the loop exits, determine the final status and report.
|
|
|
476
484
|
### 2A: Determine final status
|
|
477
485
|
|
|
478
486
|
```bash
|
|
487
|
+
# Remove the fix worktree on every exit path (success, failure, timeout, blocked).
|
|
488
|
+
cd "$REPO_PATH"
|
|
489
|
+
if [ -n "${FIX_WT:-}" ] && [ -d "$FIX_WT" ]; then
|
|
490
|
+
git -C "$REPO_PATH" worktree remove --force "$FIX_WT" 2>/dev/null || true
|
|
491
|
+
fi
|
|
492
|
+
|
|
479
493
|
case "$LOOP_STATUS" in
|
|
480
494
|
"success")
|
|
481
495
|
FINAL_STATUS="success"
|
package/commands/milestone.md
CHANGED
|
@@ -412,11 +412,18 @@ Before shipping, ensure the milestone branch is up to date with `staging` (picks
|
|
|
412
412
|
```bash
|
|
413
413
|
cd {REPO_PATH}
|
|
414
414
|
git fetch origin {STAGING_BRANCH} milestone/{slug}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
git
|
|
419
|
-
git
|
|
415
|
+
# Never mutate the main checkout: sync in a detached worktree at the milestone tip
|
|
416
|
+
SYNC_WT=$(mktemp -d)
|
|
417
|
+
git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
|
|
418
|
+
if git -C "$SYNC_WT" merge origin/{STAGING_BRANCH} --no-edit; then
|
|
419
|
+
git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
|
|
420
|
+
git worktree remove --force "$SYNC_WT"
|
|
421
|
+
else
|
|
422
|
+
# Conflicts: report to user and STOP — do not auto-resolve
|
|
423
|
+
git -C "$SYNC_WT" merge --abort 2>/dev/null || true
|
|
424
|
+
git worktree remove --force "$SYNC_WT"
|
|
425
|
+
echo "STOP: merge conflict syncing milestone/{slug} with {STAGING_BRANCH}"; exit 1
|
|
426
|
+
fi
|
|
420
427
|
```
|
|
421
428
|
|
|
422
429
|
### Step 2.5: Pre-Merge Hunk-Loss Audit (MANDATORY before creating PR)
|
|
@@ -425,6 +432,7 @@ git checkout {STAGING_BRANCH}
|
|
|
425
432
|
|
|
426
433
|
This step identifies "at-risk hunks" — staging-only content in files the milestone also modifies — and absorbs them into the milestone branch via rebase before the PR is created. After the rebase, even a squash merge will preserve all staging content because it's now part of the milestone branch's commits.
|
|
427
434
|
|
|
435
|
+
<!-- allowlist:check-spec-bash -->
|
|
428
436
|
```bash
|
|
429
437
|
cd {REPO_PATH}
|
|
430
438
|
git fetch origin {STAGING_BRANCH} milestone/{slug}
|
|
@@ -453,18 +461,19 @@ else
|
|
|
453
461
|
echo "$AT_RISK" | grep '^--- a/' | sed 's|^--- a/||'
|
|
454
462
|
|
|
455
463
|
echo "Rebasing milestone/{slug} onto origin/{STAGING_BRANCH} to absorb at-risk hunks..."
|
|
456
|
-
|
|
457
|
-
git
|
|
464
|
+
REBASE_WT=$(mktemp -d)
|
|
465
|
+
git worktree add --detach "$REBASE_WT" origin/milestone/{slug}
|
|
466
|
+
git -C "$REBASE_WT" rebase origin/{STAGING_BRANCH}
|
|
458
467
|
|
|
459
468
|
REBASE_EXIT=$?
|
|
460
469
|
if [ "$REBASE_EXIT" -ne 0 ]; then
|
|
461
470
|
# Capture conflicting files BEFORE aborting (after abort, unmerged state is gone)
|
|
462
|
-
CONFLICTING_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null)
|
|
471
|
+
CONFLICTING_FILES=$(git -C "$REBASE_WT" diff --name-only --diff-filter=U 2>/dev/null)
|
|
463
472
|
[ -z "$CONFLICTING_FILES" ] && CONFLICTING_FILES="unknown — check git status after resolving manually"
|
|
464
473
|
|
|
465
474
|
# Abort the failed rebase and return to a clean state
|
|
466
|
-
git rebase --abort 2>/dev/null || true
|
|
467
|
-
git
|
|
475
|
+
git -C "$REBASE_WT" rebase --abort 2>/dev/null || true
|
|
476
|
+
git worktree remove --force "$REBASE_WT"
|
|
468
477
|
|
|
469
478
|
# Truncate AT_RISK to safe size for issue body (avoid shell quoting issues with large diffs)
|
|
470
479
|
AT_RISK_SNIPPET=$(echo "$AT_RISK" | head -60 | sed "s/'/'\\\\''/g")
|
|
@@ -519,6 +528,7 @@ ISSUE_EOF
|
|
|
519
528
|
ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"fix(milestone): rebase conflict during hunk-loss audit for milestone/{slug}\" --body-file \"${REBASE_ISSUE_BODY_FILE}\" --label \"bug\" --label \"priority:P0\" --label \"needs-human\""))
|
|
520
529
|
```
|
|
521
530
|
|
|
531
|
+
<!-- allowlist:check-spec-bash -->
|
|
522
532
|
```bash
|
|
523
533
|
echo "STOP: Rebase conflict. Created issue for manual resolution (see /issue output above). Do NOT proceed to PR creation."
|
|
524
534
|
echo "Run '/milestone ship {slug}' again after resolving the conflict."
|
|
@@ -526,9 +536,9 @@ ISSUE_SKILL_OUTPUT=$(Skill(skill="{FORGE_SKILL_PREFIX}issue", args="--title \"fi
|
|
|
526
536
|
fi
|
|
527
537
|
|
|
528
538
|
# Rebase succeeded — push the rebased branch
|
|
529
|
-
git push origin milestone/{slug} --force-with-lease
|
|
539
|
+
git -C "$REBASE_WT" push origin HEAD:refs/heads/milestone/{slug} --force-with-lease
|
|
540
|
+
git worktree remove --force "$REBASE_WT"
|
|
530
541
|
echo "Rebase complete. Milestone branch now includes all $AT_RISK_COUNT at-risk staging hunks."
|
|
531
|
-
git checkout {STAGING_BRANCH}
|
|
532
542
|
fi
|
|
533
543
|
fi
|
|
534
544
|
```
|
|
@@ -716,11 +726,18 @@ Syncs the milestone branch with the latest default branch to pick up fast-lane f
|
|
|
716
726
|
cd {REPO_PATH}
|
|
717
727
|
DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
|
|
718
728
|
git fetch origin $DEFAULT_BRANCH milestone/{slug}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
git
|
|
723
|
-
git
|
|
729
|
+
# Never mutate the main checkout: sync in a detached worktree at the milestone tip
|
|
730
|
+
SYNC_WT=$(mktemp -d)
|
|
731
|
+
git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
|
|
732
|
+
if git -C "$SYNC_WT" merge origin/$DEFAULT_BRANCH --no-edit; then
|
|
733
|
+
git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
|
|
734
|
+
git worktree remove --force "$SYNC_WT"
|
|
735
|
+
else
|
|
736
|
+
# If conflicts, report to user and STOP
|
|
737
|
+
git -C "$SYNC_WT" merge --abort 2>/dev/null || true
|
|
738
|
+
git worktree remove --force "$SYNC_WT"
|
|
739
|
+
echo "STOP: merge conflict syncing milestone/{slug} with $DEFAULT_BRANCH"; exit 1
|
|
740
|
+
fi
|
|
724
741
|
```
|
|
725
742
|
|
|
726
743
|
Report: "Synced milestone/{slug} with latest `{DEFAULT_BRANCH}`. {N} new commits incorporated."
|