forgedock 1.16.2 → 1.18.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 +114 -0
- package/bin/engine/phases.mjs +211 -100
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +20 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +17 -1
- package/bin/engine.mjs +227 -48
- package/bin/forgedock.mjs +8 -2
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +220 -0
- package/bin/runner.mjs +70 -4
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +211 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +312 -0
- package/commands/cleanup.md +90 -2
- package/commands/deploy-info.md +8 -4
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +28 -6
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +196 -496
- package/commands/review-pr.md +512 -3134
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +13 -1
- package/commands/work-on/build/implement.md +28 -6
- package/commands/work-on/build/validate.md +5 -2
- package/commands/work-on/build.md +197 -145
- package/commands/work-on/close.md +66 -20
- package/commands/work-on/decompose.md +113 -34
- package/commands/work-on/investigate.md +15 -14
- package/commands/work-on/remediate.md +49 -19
- package/commands/work-on/review.md +7 -5
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +76 -24
- package/docs/OUTCOME-CODES.md +53 -0
- package/docs/RELEASE-NOTES.md +24 -0
- package/docs/STATS.md +63 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +19 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/packages/protocol/src/phases.js +5 -5
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -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/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +123 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +97 -3
- package/scripts/diff-size.test.sh +59 -2
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +18 -11
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -1
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-finalize.sh +1427 -0
- package/scripts/review-finalize.test.sh +833 -0
- package/scripts/review-pr-delta-wiring.test.sh +19 -72
- package/scripts/review-pr-domain-map.test.sh +23 -13
- package/scripts/review-pr-gate2.test.sh +24 -65
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +56 -80
- package/scripts/review-prepare.sh +553 -0
- package/scripts/review-prepare.test.sh +288 -0
- package/scripts/review-roster.sh +186 -0
- package/scripts/review-roster.test.sh +185 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/trusted-comments.test.sh +9 -2
- package/scripts/wait-ci-green.sh +8 -1
- package/scripts/wait-ci-green.test.sh +3 -3
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
|
|
11
|
+
import { cacheDir } from "./cache.mjs";
|
|
12
|
+
import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
|
|
13
|
+
import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
|
|
14
|
+
import { readLocalRuns } from "./local-source.mjs";
|
|
15
|
+
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const fx = (f) => join(here, "fixtures", f);
|
|
19
|
+
const load = (f) => JSON.parse(readFileSync(fx(f), "utf8"));
|
|
20
|
+
const ENV = { FORGE_TRAIL_TRUSTED_ASSOCIATIONS: "OWNER,MEMBER,COLLABORATOR" };
|
|
21
|
+
|
|
22
|
+
// A fake `gh`: lists the fixture issues (honouring `since`), serves comments per number. Counts every call.
|
|
23
|
+
function fakeGh() {
|
|
24
|
+
const comments = { 201: load("comments-201.json"), 301: load("comments-301.json") };
|
|
25
|
+
const issues = load("issues.json");
|
|
26
|
+
const calls = [];
|
|
27
|
+
const gh = (args) => {
|
|
28
|
+
calls.push(args.join(" "));
|
|
29
|
+
const path = args.find((a) => a.startsWith("repos/"));
|
|
30
|
+
const m = /issues\/(\d+)\/comments$/.exec(path);
|
|
31
|
+
if (m) return JSON.stringify(comments[m[1]] ?? []);
|
|
32
|
+
const since = /since=([^&]+)/.exec(path)?.[1];
|
|
33
|
+
return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
|
|
34
|
+
};
|
|
35
|
+
return { gh, calls };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let td;
|
|
39
|
+
beforeEach(() => { td = mkdtempSync(join(tmpdir(), "stats-")); });
|
|
40
|
+
afterEach(() => rmSync(td, { recursive: true, force: true }));
|
|
41
|
+
|
|
42
|
+
describe("telemetry comment parsing", () => {
|
|
43
|
+
it("concatenates parts, dedupes on eid and ignores untrusted or unrelated comments", () => {
|
|
44
|
+
const recs = parseTelemetryComments(load("comments-201.json"), ENV);
|
|
45
|
+
assert.deepEqual(recs.map((r) => r.eid), ["e1", "e2", "e3", "e4", "e5"]);
|
|
46
|
+
assert.ok(!recs.some((r) => r.eid === "e90"), "untrusted User comment must not be read");
|
|
47
|
+
});
|
|
48
|
+
it("skips torn lines and keeps identical-looking events that have different eids", () => {
|
|
49
|
+
const body = "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n{\"event\":\"a\",\"eid\":\"1\"}\n{\"event\":\"a\",\"eid\":\"2\"}\n{torn\n";
|
|
50
|
+
const recs = parseTelemetryComments([{ body, user: { type: "Bot" } }], ENV);
|
|
51
|
+
assert.equal(recs.length, 2);
|
|
52
|
+
});
|
|
53
|
+
it("trusts OWNER/MEMBER/COLLABORATOR, not NONE users", () => {
|
|
54
|
+
const body = "<!-- FORGE:TELEMETRY v1 -->\n```jsonl\n{\"event\":\"a\",\"eid\":\"1\"}\n```";
|
|
55
|
+
assert.equal(parseTelemetryComments([{ body, author_association: "MEMBER", user: { type: "User" } }], ENV).length, 1);
|
|
56
|
+
assert.equal(parseTelemetryComments([{ body, author_association: "NONE", user: { type: "User" } }], ENV).length, 0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("GitHub-only report (fresh machine)", () => {
|
|
61
|
+
it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
|
|
62
|
+
const { gh } = fakeGh();
|
|
63
|
+
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
64
|
+
const g = report.groups.find((x) => x.version === "1.17.0");
|
|
65
|
+
assert.equal(g.n, 1);
|
|
66
|
+
assert.equal(g.cost.cacheReadMean, 3000000);
|
|
67
|
+
assert.equal(g.cost.usdMean, 1.2);
|
|
68
|
+
assert.equal(g.time.wall.p50, 900000);
|
|
69
|
+
assert.equal(g.time.phases.build.p50, 500000);
|
|
70
|
+
assert.equal(g.flow.mergedPct, 1);
|
|
71
|
+
assert.deepEqual(g.flow.codes.map((c) => c.code), ["completed"]);
|
|
72
|
+
assert.equal(g.funnel.reviews, 1);
|
|
73
|
+
assert.equal(g.funnel.findings, 3);
|
|
74
|
+
assert.equal(g.funnel.bySeverity.HIGH.total, 2);
|
|
75
|
+
assert.equal(g.funnel.bySeverity.HIGH.ISSUE, 1);
|
|
76
|
+
assert.equal(g.funnel.bySeverity.HIGH.INPR_FIX, 1);
|
|
77
|
+
assert.equal(g.gates.acceptance.rate, 1, "duplicate eid counted once");
|
|
78
|
+
assert.equal(g.gates.malformedRate, 1);
|
|
79
|
+
assert.equal(g.gates.ci.rate, 0.5);
|
|
80
|
+
assert.deepEqual(g.issueFlow.created, { pipeline: 1 });
|
|
81
|
+
assert.equal(g.issueFlow.net, 0);
|
|
82
|
+
const u = report.groups.find((x) => x.version === "unknown");
|
|
83
|
+
assert.deepEqual(u.issueFlow.created, { "review-finding": 2, human: 1 });
|
|
84
|
+
assert.equal(u.funnel.validated, 1);
|
|
85
|
+
assert.equal(u.funnel.falsePositive, 1);
|
|
86
|
+
assert.equal(u.issueFlow.net, 2);
|
|
87
|
+
assert.equal(u.issueFlow.findingsPerMerge, null);
|
|
88
|
+
assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
|
|
89
|
+
});
|
|
90
|
+
it("matches the run-record field names written by the engine seam", () => {
|
|
91
|
+
const runs = runsFromRecords(parseTelemetryComments(load("comments-201.json"), ENV));
|
|
92
|
+
assert.equal(runs.length, 1);
|
|
93
|
+
assert.equal(runs[0].finished, true);
|
|
94
|
+
assert.equal(runs[0].phases.review.cr, 500000);
|
|
95
|
+
});
|
|
96
|
+
const rec = (run, ts, event, x = {}) => ({ event, repo: "o/r", issue: 500, run, ts, eid: run + ts + event, ...x });
|
|
97
|
+
it("collapses a retried issue (two run ids) to the latest run", () => {
|
|
98
|
+
const runs = runsFromRecords([
|
|
99
|
+
rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
|
|
100
|
+
rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "engine-error" }),
|
|
101
|
+
rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
|
|
102
|
+
rec("r_500_2", "2026-01-02T00:01:00Z", "run_terminal", { reason: "merged" }),
|
|
103
|
+
]);
|
|
104
|
+
assert.equal(runs.length, 1);
|
|
105
|
+
assert.equal(runs[0].reason, "merged");
|
|
106
|
+
assert.equal(runs[0].phases.build.cr, 1000000);
|
|
107
|
+
});
|
|
108
|
+
it("lets an unfinished latest run win over an earlier finished retried run", () => {
|
|
109
|
+
const runs = runsFromRecords([
|
|
110
|
+
rec("r_500_1", "2026-01-01T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 2000000 }),
|
|
111
|
+
rec("r_500_1", "2026-01-01T00:01:00Z", "run_terminal", { reason: "merged" }),
|
|
112
|
+
rec("r_500_2", "2026-01-02T00:00:00Z", "phase_attempt", { phase: "build", cache_read_tokens: 1000000 }),
|
|
113
|
+
]);
|
|
114
|
+
assert.equal(runs.length, 1);
|
|
115
|
+
assert.equal(runs[0].finished, false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("local runs, joins, version delta and low-n", () => {
|
|
120
|
+
const runs = () => readLocalRuns(fx("runs"));
|
|
121
|
+
const agg = (opts = {}) => aggregate({ runs: runs() }, { releaseTimes: [], ...opts });
|
|
122
|
+
|
|
123
|
+
it("applies the cost-report counting rules", () => {
|
|
124
|
+
const r = agg();
|
|
125
|
+
assert.deepEqual(r.excluded, { unfinished: 1, noUsage: 1 });
|
|
126
|
+
const g = r.groups.find((x) => x.version === "1.16.0");
|
|
127
|
+
assert.equal(g.n, 2);
|
|
128
|
+
assert.equal(g.cost.cacheReadMean, 4500000);
|
|
129
|
+
assert.equal(g.cost.perPhase.build, 4000000);
|
|
130
|
+
assert.equal(g.cost.perPhase.remediate, 0);
|
|
131
|
+
assert.equal(g.cost.perMergedIssue, 9000000);
|
|
132
|
+
assert.equal(g.flow.mergedPct, 0.5);
|
|
133
|
+
assert.equal(g.flow.needsHumanPct, 0.5);
|
|
134
|
+
assert.equal(g.flow.remediationPct, 0.5);
|
|
135
|
+
assert.equal(g.cost.usdN, 1);
|
|
136
|
+
assert.equal(g.time.wall.p50, 1500000);
|
|
137
|
+
const legacy = r.groups.find((x) => x.version === "unknown");
|
|
138
|
+
assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
|
|
139
|
+
assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
|
|
140
|
+
});
|
|
141
|
+
it("computes the delta against the previous version of the same repo", () => {
|
|
142
|
+
const g = agg().groups.find((x) => x.version === "1.17.0");
|
|
143
|
+
assert.equal(g.delta.against, "1.16.0");
|
|
144
|
+
assert.equal(g.delta.cacheReadMean, 2000000 - 4500000);
|
|
145
|
+
assert.equal(g.delta.mergedPct, 0.5);
|
|
146
|
+
assert.equal(agg().groups.find((x) => x.version === "1.16.0").delta, null);
|
|
147
|
+
});
|
|
148
|
+
it("flags samples under 10 and honours --min-n", () => {
|
|
149
|
+
assert.ok(agg().groups.every((g) => g.lowN));
|
|
150
|
+
assert.equal(agg({ minN: 2 }).groups.find((x) => x.version === "1.16.0").lowN, false);
|
|
151
|
+
assert.match(renderMarkdown(agg()), /low-n/);
|
|
152
|
+
});
|
|
153
|
+
it("agrees with scripts/cost-report.sh on the same logs", { skip: spawnSync("jq", ["--version"]).status !== 0 }, () => {
|
|
154
|
+
writeFileSync(join(td, "rel.txt"), "");
|
|
155
|
+
const out = spawnSync("bash", [join(here, "..", "..", "scripts", "cost-report.sh"), "--runs-dir", fx("runs"), "--release-times", join(td, "rel.txt")], { encoding: "utf8" }).stdout;
|
|
156
|
+
const row = out.split("\n").find((l) => l.startsWith("| 1.16.0 |"));
|
|
157
|
+
assert.ok(row?.includes("| 4500k |"), row);
|
|
158
|
+
assert.ok(row?.includes("| 9000k |"), row);
|
|
159
|
+
});
|
|
160
|
+
it("--since filters by version or date", () => {
|
|
161
|
+
assert.deepEqual(agg({ ...parseSince("1.17.0") }).groups.map((g) => g.version), ["1.17.0"]);
|
|
162
|
+
assert.ok(agg({ ...parseSince("2026-10-03") }).groups.every((g) => g.version !== "1.16.0"));
|
|
163
|
+
});
|
|
164
|
+
it("estimates versions of unversioned records from release times", () => {
|
|
165
|
+
const r = aggregate({ runs: runs().filter((x) => x.issue === 105) }, { releaseTimes: [{ v: "1.15.0", t: 1 }] });
|
|
166
|
+
assert.equal(r.groups[0].version, "1.15.0~");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("n/a degradation and rendering", () => {
|
|
171
|
+
it("renders n/a, never NaN or undefined, for missing data and empty input", () => {
|
|
172
|
+
const old = { source: "local", repo: "o/r", version: "1.0.0", issue: 1, finished: true, hydrated: false, usageEvents: 1, reason: "merged", code: null, remediated: false, phases: { build: { cr: 10, out: 0, usd: null, ms: null, attempts: 1 } } };
|
|
173
|
+
const r = aggregate({ runs: [old] });
|
|
174
|
+
const md = renderMarkdown(r);
|
|
175
|
+
assert.match(md, /n\/a/);
|
|
176
|
+
assert.doesNotMatch(md, /NaN|undefined|Infinity/);
|
|
177
|
+
assert.doesNotMatch(renderCompact(r), /NaN|undefined/);
|
|
178
|
+
assert.match(renderMarkdown(aggregate({})), /No data/);
|
|
179
|
+
assert.equal(percentile([], 90), null);
|
|
180
|
+
});
|
|
181
|
+
it("json output is parseable and carries every section", () => {
|
|
182
|
+
const { gh } = fakeGh();
|
|
183
|
+
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
184
|
+
const parsed = JSON.parse(renderJson(report));
|
|
185
|
+
for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
|
|
186
|
+
const md = renderMarkdown(report);
|
|
187
|
+
for (const h of ["### Cost", "### Time", "### Flow", "### Review funnel", "### Gates", "### Issue flow"]) assert.ok(md.includes(h), h);
|
|
188
|
+
});
|
|
189
|
+
it("orders versions numerically", () => {
|
|
190
|
+
assert.ok(compareVersions("1.9.0", "1.10.0") < 0);
|
|
191
|
+
assert.ok(compareVersions("1.10.0~", "1.10.0") === 0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("cache", () => {
|
|
196
|
+
it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
|
|
197
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
|
|
198
|
+
const a = fakeGh();
|
|
199
|
+
const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
|
|
200
|
+
assert.equal(first.fromCache, false);
|
|
201
|
+
assert.ok(!a.calls[0].includes("since="));
|
|
202
|
+
const b = fakeGh();
|
|
203
|
+
const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
204
|
+
assert.equal(second.fromCache, true);
|
|
205
|
+
assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
|
|
206
|
+
assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
|
|
207
|
+
// corrupt cache -> treated as empty -> same result from a full fetch
|
|
208
|
+
writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
|
|
209
|
+
const c = fakeGh();
|
|
210
|
+
const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
|
|
211
|
+
assert.equal(third.fromCache, false);
|
|
212
|
+
assert.equal(third.records.length, first.records.length);
|
|
213
|
+
// --refresh ignores a good cache
|
|
214
|
+
const d = fakeGh();
|
|
215
|
+
fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
|
|
216
|
+
assert.ok(!d.calls[0].includes("since="));
|
|
217
|
+
assert.equal(toModel("o/r", third).issues.length, 5);
|
|
218
|
+
});
|
|
219
|
+
it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", () => {
|
|
220
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
|
|
221
|
+
const ok = fakeGh();
|
|
222
|
+
const full = fetchGithubRecords({ repo: "o/r", gh: ok.gh, env: { ...ENV }, useCache: false });
|
|
223
|
+
const withComments = full.items.filter((i) => ok.calls.some((c) => c.includes(`/issues/${i.number}/comments`)));
|
|
224
|
+
assert.ok(withComments.length >= 2, "fixture needs at least two items with comments");
|
|
225
|
+
let commentCalls = 0;
|
|
226
|
+
// The real API honours sort=updated&direction=asc, which the watermark resume relies on; the fixture is unsorted.
|
|
227
|
+
const asc = (gh) => (args) => {
|
|
228
|
+
const out = gh(args);
|
|
229
|
+
return args.some((a) => /\/issues\?/.test(a)) ? JSON.stringify(JSON.parse(out).sort((x, y) => x.updated_at.localeCompare(y.updated_at))) : out;
|
|
230
|
+
};
|
|
231
|
+
const flaky = asc((args) => {
|
|
232
|
+
if (args[args.length - 1].endsWith("/comments") && ++commentCalls === 2) throw new Error("rate limited");
|
|
233
|
+
return ok.gh(args);
|
|
234
|
+
});
|
|
235
|
+
assert.throws(() => fetchGithubRecords({ repo: "o/r", gh: flaky, env }), /rate limited/);
|
|
236
|
+
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
237
|
+
assert.ok(saved.watermark, "partial watermark persisted");
|
|
238
|
+
assert.ok(Object.keys(saved.items).length > 0 && Object.keys(saved.items).length < full.items.length);
|
|
239
|
+
const b = fakeGh();
|
|
240
|
+
const resumed = fetchGithubRecords({ repo: "o/r", gh: asc(b.gh), env });
|
|
241
|
+
assert.equal(resumed.fromCache, true);
|
|
242
|
+
assert.match(b.calls[0], /since=/);
|
|
243
|
+
assert.deepEqual(resumed.records.map((r) => r.eid).sort(), full.records.map((r) => r.eid).sort());
|
|
244
|
+
});
|
|
245
|
+
it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", () => {
|
|
246
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-midcrawl") };
|
|
247
|
+
const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
248
|
+
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" } }));
|
|
249
|
+
let listCalls = 0;
|
|
250
|
+
let maxUpdated = 250;
|
|
251
|
+
// Page-aware fake of the real API: asc by updated_at, honours since (inclusive), per_page and page. After the first
|
|
252
|
+
// listing call an already-served item is bumped, which moves it to the end of the ordering.
|
|
253
|
+
const gh = (args) => {
|
|
254
|
+
const path = args.find((a) => a.startsWith("repos/"));
|
|
255
|
+
const q = new URLSearchParams(path.split("?")[1]);
|
|
256
|
+
listCalls++;
|
|
257
|
+
const since = q.get("since");
|
|
258
|
+
const sorted = store.filter((i) => !since || i.updated_at >= since).sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.number - b.number);
|
|
259
|
+
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
260
|
+
const out = JSON.stringify(sorted.slice((page - 1) * per, page * per));
|
|
261
|
+
if (listCalls === 1) store[4].updated_at = ts(++maxUpdated);
|
|
262
|
+
return out;
|
|
263
|
+
};
|
|
264
|
+
const first = fetchGithubRecords({ repo: "o/r", gh, env });
|
|
265
|
+
assert.equal(first.items.length, 250, "every item is cached after the crawl, none skipped by the shift");
|
|
266
|
+
assert.equal(new Set(first.items.map((i) => i.number)).size, 250);
|
|
267
|
+
// The bumped item moved past the crawl cursor, so the next incremental run (and its overlap window) still sees it.
|
|
268
|
+
store[9].updated_at = ts(++maxUpdated); // another item updated after the first run
|
|
269
|
+
const second = fetchGithubRecords({ repo: "o/r", gh, env });
|
|
270
|
+
assert.equal(second.fromCache, true);
|
|
271
|
+
assert.equal(second.items.length, 250);
|
|
272
|
+
assert.equal(second.items.find((i) => i.number === 10).updatedAt, store[9].updated_at);
|
|
273
|
+
assert.equal(second.items.find((i) => i.number === 5).updatedAt, store[4].updated_at);
|
|
274
|
+
});
|
|
275
|
+
it("terminates and drains a full page whose items all share one updated_at", () => {
|
|
276
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-sameTs") };
|
|
277
|
+
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" } }));
|
|
278
|
+
const gh = (args) => {
|
|
279
|
+
const q = new URLSearchParams(args.find((a) => a.startsWith("repos/")).split("?")[1]);
|
|
280
|
+
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
281
|
+
const since = q.get("since");
|
|
282
|
+
return JSON.stringify(store.filter((i) => !since || i.updated_at >= since).slice((page - 1) * per, page * per));
|
|
283
|
+
};
|
|
284
|
+
assert.equal(fetchGithubRecords({ repo: "o/r", gh, env }).items.length, 230);
|
|
285
|
+
});
|
|
286
|
+
it("rejects an unsafe repo before any gh call", () => {
|
|
287
|
+
const { gh, calls } = fakeGh();
|
|
288
|
+
assert.throws(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
|
|
289
|
+
assert.equal(calls.length, 0);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("cli", () => {
|
|
294
|
+
it("parses flags and rejects bad input", () => {
|
|
295
|
+
const o = parseArgs(["--since", "1.2.0", "--repo=o/r", "--json", "--refresh", "--min-n", "3"]);
|
|
296
|
+
assert.deepEqual([o.since, o.repo, o.json, o.refresh, o.minN], ["1.2.0", "o/r", true, true, 3]);
|
|
297
|
+
assert.throws(() => parseArgs(["--since"]), /needs a value/);
|
|
298
|
+
assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
|
|
299
|
+
assert.throws(() => parseSince("yesterday"), /--since must be/);
|
|
300
|
+
});
|
|
301
|
+
it("--help exits 0 and importing the module did not run it", () => {
|
|
302
|
+
const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
|
|
303
|
+
assert.equal(r.status, 0);
|
|
304
|
+
assert.match(r.stdout, /--since/);
|
|
305
|
+
assert.match(r.stdout, /--refresh/);
|
|
306
|
+
});
|
|
307
|
+
it("runStats returns 2 for an unusable repo", async () => {
|
|
308
|
+
const real = process.stderr.write;
|
|
309
|
+
process.stderr.write = () => true;
|
|
310
|
+
try { assert.equal(await runStats(["--repo", "bad repo"]), 2); } finally { process.stderr.write = real; }
|
|
311
|
+
});
|
|
312
|
+
});
|
package/commands/cleanup.md
CHANGED
|
@@ -10,7 +10,7 @@ install: extras
|
|
|
10
10
|
|
|
11
11
|
**Input**: $ARGUMENTS
|
|
12
12
|
|
|
13
|
-
Scan the entire development environment for rot and fix it. This is a maintenance command — run periodically or after large orchestration batches. It covers 6 domains: stale labels, orphaned issues, worktree/branch pruning, milestone hygiene, and Project board sync.
|
|
13
|
+
Scan the entire development environment for rot and fix it. This is a maintenance command — run periodically or after large orchestration batches. It covers 6 domains: stale labels, orphaned issues and dead claims boards, worktree/branch pruning, milestone hygiene, and Project board sync.
|
|
14
14
|
|
|
15
15
|
**NEVER use plan mode (EnterPlanMode)** — it breaks execution context.
|
|
16
16
|
|
|
@@ -46,7 +46,7 @@ All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{STAGING_BRANCH}`, `{PROJECT_BOARD
|
|
|
46
46
|
| Input | Action |
|
|
47
47
|
|-------|--------|
|
|
48
48
|
| `labels` or empty | Fix stale/missing workflow labels on closed issues; detect OPEN issues stuck in intermediate states (Phase 1D) |
|
|
49
|
-
| `orphans` | Close open issues whose PRs are already merged |
|
|
49
|
+
| `orphans` | Close open issues whose PRs are already merged (Phase 2A/2B), and close dead orchestrate claims boards (Phase 2C) |
|
|
50
50
|
| `branches` | Prune worktrees and remote branches for merged PRs |
|
|
51
51
|
| `milestones` | Report milestones with 0 open issues (advisory — never closes) |
|
|
52
52
|
| `board` | Sync closed issues to Project board with correct terminal state |
|
|
@@ -234,6 +234,89 @@ gh issue edit $NUM {GH_FLAG} --remove-label "workflow:in-review,workflow:remedia
|
|
|
234
234
|
|
|
235
235
|
Also check open issues with `workflow:building` — same pattern (search for merged PRs referencing them).
|
|
236
236
|
|
|
237
|
+
### 2C: Close dead orchestrate claims boards <!-- Added: forge#3699 -->
|
|
238
|
+
|
|
239
|
+
Each `/orchestrate` batch creates one claims board (`orchestrate: claims board for batch <id>`, label `automation`) with no PR of its own, so 2A/2B never match it. `/orchestrate` Phase 5 Step 5C closes the board on a clean finish; batches that end by compaction, freeze, crash or a fresh-context resume never reach it. This sweep is the durable safety net. Runs for `orphans` and `all`; respects `DRY_RUN`.
|
|
240
|
+
|
|
241
|
+
A board is closed only when **all** identity checks pass and **one** closing condition holds:
|
|
242
|
+
|
|
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.
|
|
245
|
+
- Close when the last `FORGE:LEASE*` comment is a `FORGE:LEASE_RELEASED`.
|
|
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).
|
|
247
|
+
- Close when there is no lease/claim comment and the issue itself is older than `BOARD_STALE_HOURS`.
|
|
248
|
+
- Never close a board whose last lease event is an unreleased `FORGE:LEASE` younger than `BOARD_STALE_HOURS`.
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
BOARD_STALE_HOURS=${BOARD_STALE_HOURS:-6}
|
|
252
|
+
BOARD_CUTOFF=$(date -u -d "${BOARD_STALE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
|
253
|
+
|| date -u -v-${BOARD_STALE_HOURS}H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
|
254
|
+
|| echo "")
|
|
255
|
+
BOARDS_CLOSED=0
|
|
256
|
+
|
|
257
|
+
if [ -z "$BOARD_CUTOFF" ]; then
|
|
258
|
+
echo "WARNING: cannot compute BOARD_STALE_HOURS cutoff — only released boards will be closed"
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
# REST (not `gh issue list`): it exposes author_association and user.type. Boards are created by the
|
|
262
|
+
# pipeline's GitHub App, which GitHub reports as CONTRIBUTOR, so trust is the same predicate as
|
|
263
|
+
# scripts/trusted-comments.sh: OWNER/MEMBER/COLLABORATOR or a Bot account.
|
|
264
|
+
BOARDS=$(gh api --paginate "repos/{GH_REPO}/issues?state=open&labels=automation&per_page=100" 2>/dev/null \
|
|
265
|
+
| jq -rs '.[][] | select(.pull_request == null)
|
|
266
|
+
| select(((.title // "") | startswith("orchestrate: claims board for batch"))
|
|
267
|
+
and ((.body // "") | contains("<!-- FORGE:COORD_ISSUE -->"))
|
|
268
|
+
and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot")))
|
|
269
|
+
| "\(.number)\t\(.created_at)"' 2>/dev/null || echo "")
|
|
270
|
+
|
|
271
|
+
while IFS=$'\t' read -r BNUM BCREATED; do
|
|
272
|
+
[ -z "$BNUM" ] && continue
|
|
273
|
+
|
|
274
|
+
# --paginate emits one array per page: slurp (jq -s) and flatten BEFORE taking `last`,
|
|
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.
|
|
280
|
+
LAST_LEASE=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
|
|
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 "")
|
|
284
|
+
LAST_ACTIVITY=$(gh api --paginate "repos/{GH_REPO}/issues/${BNUM}/comments" 2>/dev/null \
|
|
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 "")
|
|
287
|
+
|
|
288
|
+
REASON=""
|
|
289
|
+
if [ -n "$LAST_LEASE" ] && [ "$(echo "$LAST_LEASE" | jq -r '.released')" = "true" ]; then
|
|
290
|
+
REASON="last lease event is FORGE:LEASE_RELEASED"
|
|
291
|
+
elif [ -n "$BOARD_CUTOFF" ]; then
|
|
292
|
+
# ISO-8601 UTC timestamps compare correctly as strings
|
|
293
|
+
LAST_SEEN="${LAST_ACTIVITY:-$BCREATED}"
|
|
294
|
+
if [ -n "$LAST_SEEN" ] && [ "$LAST_SEEN" \< "$BOARD_CUTOFF" ]; then
|
|
295
|
+
REASON="no lease/claim activity since ${LAST_SEEN} (older than ${BOARD_STALE_HOURS}h)"
|
|
296
|
+
fi
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
if [ -z "$REASON" ]; then
|
|
300
|
+
echo "KEEP: board #$BNUM has a live or recent lease"
|
|
301
|
+
continue
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
if [ "${DRY_RUN:-false}" = "true" ]; then
|
|
305
|
+
echo "DRY-RUN: would close claims board #$BNUM — $REASON"
|
|
306
|
+
continue
|
|
307
|
+
fi
|
|
308
|
+
|
|
309
|
+
# Closing an already-closed issue is a no-op; a gh failure is non-fatal.
|
|
310
|
+
gh issue close "$BNUM" {GH_FLAG} --comment "Closing dead claims board — ${REASON}.
|
|
311
|
+
|
|
312
|
+
Closed automatically by \`/cleanup\` Phase 2C." 2>/dev/null \
|
|
313
|
+
&& { echo "Closed claims board #$BNUM ($REASON)"; BOARDS_CLOSED=$((BOARDS_CLOSED + 1)); } \
|
|
314
|
+
|| echo "WARNING: failed to close claims board #$BNUM — non-fatal" # allowlist:check-command-side-effects
|
|
315
|
+
done <<< "$BOARDS"
|
|
316
|
+
|
|
317
|
+
echo "Claims boards closed: $BOARDS_CLOSED"
|
|
318
|
+
```
|
|
319
|
+
|
|
237
320
|
---
|
|
238
321
|
|
|
239
322
|
## Phase 3: Worktree & Branch Pruning
|
|
@@ -599,6 +682,11 @@ fi
|
|
|
599
682
|
|-------|-----------|--------|
|
|
600
683
|
| #{N} | PR #{M} | Closed |
|
|
601
684
|
|
|
685
|
+
### Claims Boards Closed
|
|
686
|
+
| Issue | Reason | Action |
|
|
687
|
+
|-------|--------|--------|
|
|
688
|
+
| #{N} | LEASE_RELEASED / stale > {BOARD_STALE_HOURS}h | Closed |
|
|
689
|
+
|
|
602
690
|
### Worktrees & Branches Pruned
|
|
603
691
|
| Type | Count |
|
|
604
692
|
|------|-------|
|
package/commands/deploy-info.md
CHANGED
|
@@ -56,13 +56,15 @@ else
|
|
|
56
56
|
# Step 2: Check for open review-finding issues referencing each PR
|
|
57
57
|
OPEN_FINDINGS_SUMMARY=""
|
|
58
58
|
for pr_num in $ALL_PR_NUMBERS; do
|
|
59
|
+
# Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
|
|
60
|
+
FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
|
|
59
61
|
OPEN_FINDINGS=$(gh issue list -R {GH_REPO} \
|
|
60
62
|
--label "review-finding" \
|
|
61
63
|
--state open \
|
|
62
64
|
--search "PR #${pr_num}" \
|
|
63
65
|
--limit 20 \
|
|
64
|
-
--json number,title \
|
|
65
|
-
|
|
66
|
+
--json number,title,body 2>/dev/null \
|
|
67
|
+
| jq -r --arg n "${pr_num}" "$FINDING_PR_FILTER" 2>/dev/null) # uses FINDING_PR_FILTER
|
|
66
68
|
|
|
67
69
|
if [ -n "$OPEN_FINDINGS" ]; then
|
|
68
70
|
OPEN_FINDINGS_SUMMARY="${OPEN_FINDINGS_SUMMARY}
|
|
@@ -304,13 +306,15 @@ if [ -z "$TRAIN_PR_NUMBER" ]; then
|
|
|
304
306
|
TRAIN_STATUS="NO_TRAIN — no open staging→main PR. Run /review-pr-staging to open one."
|
|
305
307
|
else
|
|
306
308
|
# Step 2: Check whether the train is held (has blocking findings)
|
|
309
|
+
# Anchored post-filter (same as review-pr-staging.md): the search is a coarse candidate fetch only
|
|
310
|
+
FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
|
|
307
311
|
HELD_BY=$(gh issue list {GH_FLAG} \
|
|
308
312
|
--label "review-finding" \
|
|
309
313
|
--state open \
|
|
310
314
|
--search "PR #${TRAIN_PR_NUMBER}" \
|
|
311
315
|
--limit 20 \
|
|
312
|
-
--json number,title \
|
|
313
|
-
|
|
316
|
+
--json number,title,body 2>/dev/null \
|
|
317
|
+
| jq -r --arg n "${TRAIN_PR_NUMBER}" "$FINDING_PR_FILTER" 2>/dev/null | sed 's/^ - //' || true) # uses FINDING_PR_FILTER
|
|
314
318
|
|
|
315
319
|
if [ -n "$HELD_BY" ]; then
|
|
316
320
|
TRAIN_STATUS="HELD"
|
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"
|