forgedock 1.26.0 → 1.28.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/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* scripts/bootstrap-create.mjs — idempotent, resumable backlog creation for /bootstrap (#4176, part of #4155)
|
|
6
|
+
*
|
|
7
|
+
* Takes a plan that passes scripts/bootstrap-validate.mjs and makes it real on GitHub: the spike parent issue,
|
|
8
|
+
* milestones in build order with their `milestone/<slug>` branches, every sub-issue (guardrails and domain rules
|
|
9
|
+
* injected into its Context, `#{{KEY}}` placeholders resolved), board items with Status=Todo, Priority and Component,
|
|
10
|
+
* a FORGE:BOOTSTRAP_PLAN and a FORGE:DECOMPOSED comment on the spike, and `workflow:decomposed` via
|
|
11
|
+
* scripts/transition-label.sh. Nothing is written when the plan or the spike doc is invalid.
|
|
12
|
+
*
|
|
13
|
+
* Re-running is safe: the spike is reused by exact title, milestones by title, branches when present, sub-issues by
|
|
14
|
+
* exact title plus a `**Parent**: #<spike>` line, comments when a trusted marker comment already exists.
|
|
15
|
+
*
|
|
16
|
+
* Plan fields on top of the validator's shape (all optional):
|
|
17
|
+
* "spike": "SPIKE" plan key of the spike parent issue (default "SPIKE")
|
|
18
|
+
* "spikeDoc": "docs/engineering/technical-spike.md" spike doc, checked for its sections (checkSpikeDoc)
|
|
19
|
+
* "guardrails": ["no auto-send"], "domainRules": ["India-only data residency"]
|
|
20
|
+
* issues[].priority: "p0".."p3" (label priority:P<n> + board Priority), issues[].component: board Component label,
|
|
21
|
+
* issues[].estimatedDiffLines: number (FORGE:BOOTSTRAP_PLAN estimate)
|
|
22
|
+
* "costPerIssueUsd": 3 dry-run cost estimate per build issue (#4177)
|
|
23
|
+
*
|
|
24
|
+
* Usage: node scripts/bootstrap-create.mjs <plan.json> [--dry-run] [--json] [--repo OWNER/NAME] [--config forge.yaml]
|
|
25
|
+
* [--base <branch>] [--cost-per-issue <usd>]
|
|
26
|
+
* Cost: plan.costPerIssueUsd, else --cost-per-issue, else DEFAULT_ISSUE_COST_USD, times the build sub-issues.
|
|
27
|
+
* Repo: --repo, else forge.yaml project.owner/project.repo. Base: --base, else branches.staging, else "staging".
|
|
28
|
+
* Exit: 0 created (or dry-run preview); 1 plan or spike doc invalid (nothing written); 2 usage; 3 GitHub error.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { validatePlan, formatErrors, resolvePlaceholders, isNeedsHuman } from "./bootstrap-validate.mjs";
|
|
35
|
+
import { milestoneSlug } from "../bin/lean/lane.mjs";
|
|
36
|
+
|
|
37
|
+
const SELF_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
38
|
+
const DEFAULT_SPIKE_DOC = "docs/engineering/technical-spike.md";
|
|
39
|
+
const PLACEHOLDER_RE = /#\{\{[^{}]*\}\}/;
|
|
40
|
+
/** Planning estimate of agent spend per build issue (one lean lane) for the dry-run preview; not a measured figure. */
|
|
41
|
+
export const DEFAULT_ISSUE_COST_USD = 3;
|
|
42
|
+
|
|
43
|
+
/** Spike-doc sections (templates/bootstrap/technical-spike.md), matched as a prefix of a `## ` heading. */
|
|
44
|
+
export const SPIKE_SECTIONS = [
|
|
45
|
+
"Stack", "Repo layout", "Runtime topology", "Data model", "Security and privacy",
|
|
46
|
+
"Design system and quality gates", "Route/API map", "Credentials", "Milestone plan",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** `## ` sections of a markdown text, fenced code ignored: [{ heading, lines }]. */
|
|
50
|
+
function h2Sections(text) {
|
|
51
|
+
const out = [];
|
|
52
|
+
let cur = null, fence = null;
|
|
53
|
+
for (const line of String(text ?? "").replace(/\r\n/g, "\n").split("\n")) {
|
|
54
|
+
const f = line.match(/^[ \t]*(`{3,}|~{3,})/);
|
|
55
|
+
if (f) fence = fence ? (f[1][0] === fence ? null : fence) : f[1][0];
|
|
56
|
+
const h = !fence && !f && line.match(/^##(?!#)[ \t]+(.+?)[ \t]*$/);
|
|
57
|
+
if (h) { cur = { heading: h[1], lines: [] }; out.push(cur); continue; }
|
|
58
|
+
if (cur) cur.lines.push(line);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check a spike doc: every SPIKE_SECTIONS section present, each with at least one ADR-style `**Rationale**:` line.
|
|
65
|
+
* @returns {{ ok: boolean, missing: string[], noRationale: string[] }}
|
|
66
|
+
*/
|
|
67
|
+
export function checkSpikeDoc(text) {
|
|
68
|
+
const secs = h2Sections(text);
|
|
69
|
+
const missing = [], noRationale = [];
|
|
70
|
+
for (const name of SPIKE_SECTIONS) {
|
|
71
|
+
const s = secs.find((x) => x.heading.toLowerCase().startsWith(name.toLowerCase()));
|
|
72
|
+
if (!s) missing.push(name);
|
|
73
|
+
else if (!s.lines.some((l) => /\*\*Rationale\*\*:/.test(l))) noRationale.push(name);
|
|
74
|
+
}
|
|
75
|
+
return { ok: missing.length === 0 && noRationale.length === 0, missing, noRationale };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Append the plan's guardrails and domain rules to the end of a body's `## Context` section (idempotent).
|
|
80
|
+
* Bodies without a Context section are returned unchanged (the validator rejects those).
|
|
81
|
+
*/
|
|
82
|
+
export function injectGuardrails(body, guardrails = [], domainRules = []) {
|
|
83
|
+
const g = (guardrails ?? []).filter((x) => typeof x === "string" && x.trim());
|
|
84
|
+
const d = (domainRules ?? []).filter((x) => typeof x === "string" && x.trim());
|
|
85
|
+
if ((!g.length && !d.length) || /^\*\*Guardrails\*\*:/m.test(body)) return body;
|
|
86
|
+
const lines = String(body).split("\n");
|
|
87
|
+
let start = -1, end = lines.length, fence = null;
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
const f = lines[i].match(/^[ \t]*(`{3,}|~{3,})/);
|
|
90
|
+
if (f) { fence = fence ? (f[1][0] === fence ? null : fence) : f[1][0]; continue; }
|
|
91
|
+
if (fence || !/^##(?!#)[ \t]+/.test(lines[i])) continue;
|
|
92
|
+
if (start < 0 && /^##[ \t]+context\b/i.test(lines[i])) start = i;
|
|
93
|
+
else if (start >= 0) { end = i; break; }
|
|
94
|
+
}
|
|
95
|
+
if (start < 0) return body;
|
|
96
|
+
while (end > start + 1 && lines[end - 1].trim() === "") end--;
|
|
97
|
+
const block = [""];
|
|
98
|
+
if (g.length) block.push("**Guardrails**:", ...g.map((x) => `- ${x}`));
|
|
99
|
+
if (d.length) block.push(...(g.length ? [""] : []), "**Domain rules**:", ...d.map((x) => `- ${x}`));
|
|
100
|
+
lines.splice(end, 0, ...block);
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Sub-issues (every plan issue but the spike) in dependency order: milestone order, then a stable topo sort. */
|
|
105
|
+
export function subIssueOrder(plan) {
|
|
106
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
107
|
+
const mIdx = new Map(plan.milestones.map((m, i) => [m.key, i]));
|
|
108
|
+
const pending = plan.issues.filter((it) => it.key !== spikeKey)
|
|
109
|
+
.map((it, i) => ({ it, i })).sort((a, b) => mIdx.get(a.it.milestone) - mIdx.get(b.it.milestone) || a.i - b.i)
|
|
110
|
+
.map((x) => x.it);
|
|
111
|
+
const keys = new Set(pending.map((it) => it.key));
|
|
112
|
+
const done = new Set(), out = [];
|
|
113
|
+
while (pending.length) {
|
|
114
|
+
const at = pending.findIndex((it) => (it.deps ?? []).every((d) => !keys.has(d) || done.has(d)));
|
|
115
|
+
const [next] = pending.splice(at < 0 ? 0 : at, 1);
|
|
116
|
+
done.add(next.key);
|
|
117
|
+
out.push(next);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The priority label for an issue (`p1` -> `priority:P1`), or null. */
|
|
123
|
+
export function priorityLabel(issue) {
|
|
124
|
+
const m = String(issue?.priority ?? "").match(/^p([0-3])$/i);
|
|
125
|
+
return m ? `priority:P${m[1]}` : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Plan preview for --dry-run: milestones, titles, DAG summary, estimated agent cost. No GitHub access. */
|
|
129
|
+
export function planPreview(plan, { costPerIssueUsd } = {}) {
|
|
130
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
131
|
+
const spike = plan.issues.find((it) => it.key === spikeKey);
|
|
132
|
+
const subs = subIssueOrder(plan);
|
|
133
|
+
const edges = subs.reduce((n, it) => n + (it.deps ?? []).filter((d) => d !== spikeKey).length, 0);
|
|
134
|
+
const human = subs.filter(isNeedsHuman);
|
|
135
|
+
const lines = ["## Bootstrap plan preview (dry run: nothing written to GitHub)", "",
|
|
136
|
+
`Spike: ${spike ? spike.title : "(missing)"} — doc \`${plan.spikeDoc ?? DEFAULT_SPIKE_DOC}\``, ""];
|
|
137
|
+
plan.milestones.forEach((m, i) => {
|
|
138
|
+
const its = subs.filter((it) => it.milestone === m.key);
|
|
139
|
+
lines.push(`### ${i + 1}. ${m.title} (\`milestone/${milestoneSlug(m.title)}\`, ${its.length} issues)`);
|
|
140
|
+
for (const it of its) {
|
|
141
|
+
const deps = (it.deps ?? []).filter((d) => d !== spikeKey);
|
|
142
|
+
lines.push(`- ${it.title}${isNeedsHuman(it) ? " [needs-human]" : ""}${deps.length ? ` — depends on ${deps.join(", ")}` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
lines.push("");
|
|
145
|
+
});
|
|
146
|
+
lines.push(`DAG: ${subs.length} sub-issues (${subs.length - human.length} build, ${human.length} needs-human), ${edges} dependency edges, ${plan.milestones.length} milestones.`);
|
|
147
|
+
const rate = [plan.costPerIssueUsd, costPerIssueUsd].find((r) => Number.isFinite(r) && r > 0) ?? DEFAULT_ISSUE_COST_USD;
|
|
148
|
+
const build = subs.length - human.length;
|
|
149
|
+
lines.push(`Estimated agent cost: ~$${(build * rate).toFixed(2)} (${build} build issues × $${rate}/issue; needs-human and spike excluded).`);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `<!-- FORGE:BOOTSTRAP_PLAN -->` body, the shape commands/work-on/decompose.md Phase D0 reads. */
|
|
154
|
+
export function bootstrapPlanComment(plan) {
|
|
155
|
+
const subs = subIssueOrder(plan);
|
|
156
|
+
const pos = new Map(subs.map((it, i) => [it.key, i + 1]));
|
|
157
|
+
const items = subs.map((it, i) => {
|
|
158
|
+
const deps = (it.deps ?? []).filter((d) => pos.has(d)).map((d) => pos.get(d));
|
|
159
|
+
const est = Number.isFinite(it.estimatedDiffLines) ? ` — ESTIMATED_DIFF_LINES: ${it.estimatedDiffLines}` : "";
|
|
160
|
+
return `${i + 1}. ${it.title}${est}${deps.length ? ` _(depends on ${deps.join(", ")})_` : ""}`;
|
|
161
|
+
});
|
|
162
|
+
return ["<!-- FORGE:BOOTSTRAP_PLAN -->", "## Bootstrap Plan", "", `Spike: \`${plan.spikeDoc ?? DEFAULT_SPIKE_DOC}\``, "",
|
|
163
|
+
"### Decomposition Assessment", "", "**YES**", "", ...items, ""].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `<!-- FORGE:DECOMPOSED -->` body in the decompose Phase D5 format (`- #N: TITLE _(depends on #M)_`). */
|
|
167
|
+
export function decomposedComment(plan, numbers, { attribution = true } = {}) {
|
|
168
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
169
|
+
const subs = subIssueOrder(plan);
|
|
170
|
+
const items = subs.map((it) => {
|
|
171
|
+
const deps = (it.deps ?? []).filter((d) => d !== spikeKey && numbers.has(d)).map((d) => `#${numbers.get(d)}`);
|
|
172
|
+
return `- #${numbers.get(it.key)}: ${it.title}${deps.length ? ` _(depends on ${deps.join(", ")})_` : ""}${isNeedsHuman(it) ? " — needs-human" : ""}`;
|
|
173
|
+
});
|
|
174
|
+
return ["<!-- FORGE:DECOMPOSED -->", "## Decomposition Complete", "",
|
|
175
|
+
"This spike has been broken into sub-issues by /bootstrap. Each sub-issue runs through its own /work-on pipeline independently.", "",
|
|
176
|
+
"**Plan source**: bootstrap", "", "### Sub-Issues Created", "", ...items, "", "### Decomposition Rationale", "",
|
|
177
|
+
`Milestones in build order: ${plan.milestones.map((m) => m.title).join(" → ")}. Needs-human issues are referenced by code issues but never block them; mocks keep dev and CI green.`,
|
|
178
|
+
...(attribution ? ["", "> Pipeline powered by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock)"] : []),
|
|
179
|
+
"<!-- FORGE:DECOMPOSED:COMPLETE -->"].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Human hand-off: created links, needs-human links, `/orchestrate` per milestone in build order. */
|
|
183
|
+
export function handoffText(result) {
|
|
184
|
+
const link = (x) => `- #${x.number} ${x.title} — ${x.url}${x.reused ? " (reused)" : ""}`;
|
|
185
|
+
const lines = ["## Bootstrap hand-off", "", `Spike: #${result.spike.number} ${result.spike.title} — ${result.spike.url}`, "", "### Created"];
|
|
186
|
+
lines.push(...result.issues.filter((x) => !x.needsHuman).map(link));
|
|
187
|
+
lines.push("", "### Needs a human");
|
|
188
|
+
const human = result.issues.filter((x) => x.needsHuman);
|
|
189
|
+
lines.push(...(human.length ? human.map(link) : ["- none"]));
|
|
190
|
+
lines.push("", "### Next commands (in build order)");
|
|
191
|
+
result.milestones.forEach((m, i) => lines.push(`${i + 1}. ${m.orchestrate}`));
|
|
192
|
+
if (result.notes.length) lines.push("", "### Notes", ...result.notes.map((n) => `- ${n}`));
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const parseIssueNumber = (out) => {
|
|
197
|
+
const m = String(out).match(/\/issues\/(\d+)/);
|
|
198
|
+
if (!m) throw new Error(`could not read the created issue number from: ${String(out).trim().slice(0, 200)}`);
|
|
199
|
+
return Number(m[1]);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Create (or reuse) the whole backlog. All GitHub access goes through `io`:
|
|
204
|
+
* io.gh(args, input?) -> stdout string (throws on failure)
|
|
205
|
+
* io.sh(cmd, args, input?) -> stdout string (bash helpers: transition-label.sh, trusted-comments.sh)
|
|
206
|
+
* @returns {object} result for handoffText (spike, issues, milestones, notes)
|
|
207
|
+
*/
|
|
208
|
+
export function createBacklog(plan, { io, repo, config = {}, base }) {
|
|
209
|
+
const spikeKey = plan.spike ?? "SPIKE";
|
|
210
|
+
const spikePlan = plan.issues.find((it) => it.key === spikeKey);
|
|
211
|
+
if (!spikePlan) throw new Error(`plan has no spike issue with key ${spikeKey}`);
|
|
212
|
+
const notes = [];
|
|
213
|
+
const board = config.project_board ?? {};
|
|
214
|
+
const pattern = config.branches?.feature_pattern || "milestone/{slug}";
|
|
215
|
+
const baseBranch = base || config.branches?.staging || "staging";
|
|
216
|
+
const gh = (args, input) => io.gh(args, input);
|
|
217
|
+
const json = (args) => JSON.parse(gh(args) || "null");
|
|
218
|
+
|
|
219
|
+
// Labels: create only the missing ones (never overwrite an existing label's colour or description).
|
|
220
|
+
const haveLabels = new Set((json(["label", "list", "-R", repo, "--limit", "1000", "--json", "name"]) ?? []).map((l) => l.name));
|
|
221
|
+
const wanted = new Set(plan.issues.flatMap((it) => [...(it.labels ?? []), priorityLabel(it)]).filter(Boolean));
|
|
222
|
+
for (const l of wanted) if (!haveLabels.has(l)) gh(["label", "create", l, "-R", repo]);
|
|
223
|
+
|
|
224
|
+
// Milestones in build order, each with its lane branch.
|
|
225
|
+
const ghMilestones = json(["api", `repos/${repo}/milestones?state=all&per_page=100`]) ?? [];
|
|
226
|
+
let baseSha = null;
|
|
227
|
+
const milestones = plan.milestones.map((m) => {
|
|
228
|
+
let reused = ghMilestones.some((g) => g.title === m.title);
|
|
229
|
+
if (!reused) gh(["api", `repos/${repo}/milestones`, "--method", "POST", "-f", `title=${m.title}`, "-f", `description=${m.description ?? ""}`, "-f", "state=open"]);
|
|
230
|
+
const slug = milestoneSlug(m.title);
|
|
231
|
+
if (!slug) throw new Error(`milestone "${m.title}" gives an empty branch slug`);
|
|
232
|
+
const branch = pattern.replace("{slug}", slug);
|
|
233
|
+
let exists = true;
|
|
234
|
+
try { gh(["api", `repos/${repo}/git/ref/heads/${branch}`]); } catch { exists = false; }
|
|
235
|
+
if (!exists) {
|
|
236
|
+
baseSha ??= gh(["api", `repos/${repo}/git/ref/heads/${baseBranch}`, "--jq", ".object.sha"]).trim();
|
|
237
|
+
gh(["api", `repos/${repo}/git/refs`, "--method", "POST", "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`]);
|
|
238
|
+
}
|
|
239
|
+
return { key: m.key, title: m.title, branch, reused, orchestrate: `/orchestrate milestone "${m.title}"` };
|
|
240
|
+
});
|
|
241
|
+
const milestoneTitle = new Map(plan.milestones.map((m) => [m.key, m.title]));
|
|
242
|
+
|
|
243
|
+
// Existing issues, read once (no search-index lag on a resume right after a create).
|
|
244
|
+
const existing = json(["issue", "list", "-R", repo, "--state", "all", "--limit", "2000", "--json", "number,title,body,url,labels"]) ?? [];
|
|
245
|
+
const numbers = new Map(Object.entries(plan.numbers ?? {}).filter(([, n]) => Number.isSafeInteger(n) && n > 0));
|
|
246
|
+
const finalBody = (it) => injectGuardrails(resolvePlaceholders(it.body, numbers), plan.guardrails, plan.domainRules);
|
|
247
|
+
const records = new Map();
|
|
248
|
+
const ensure = (it, match) => {
|
|
249
|
+
const found = existing.find((x) => x.title === it.title && match(x));
|
|
250
|
+
if (found) {
|
|
251
|
+
numbers.set(it.key, found.number);
|
|
252
|
+
records.set(it.key, { number: found.number, url: found.url, body: found.body ?? "", labels: (found.labels ?? []).map((l) => l.name), reused: true });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const args = ["issue", "create", "-R", repo, "--title", it.title, "--body-file", "-", "--milestone", milestoneTitle.get(it.milestone)];
|
|
256
|
+
for (const l of [...(it.labels ?? []), priorityLabel(it)].filter(Boolean)) args.push("--label", l);
|
|
257
|
+
const body = finalBody(it);
|
|
258
|
+
const out = gh(args, body);
|
|
259
|
+
const number = parseIssueNumber(out);
|
|
260
|
+
numbers.set(it.key, number);
|
|
261
|
+
records.set(it.key, { number, url: String(out).trim().split("\n").pop(), body, labels: [], reused: false });
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
ensure(spikePlan, () => true);
|
|
265
|
+
const spikeNo = numbers.get(spikeKey);
|
|
266
|
+
const parentRe = new RegExp(`\\*\\*Parent\\*\\*:?\\s*#${spikeNo}(?!\\d)`);
|
|
267
|
+
const subs = subIssueOrder(plan);
|
|
268
|
+
for (const it of subs) ensure(it, (x) => parentRe.test(x.body ?? ""));
|
|
269
|
+
|
|
270
|
+
// Edit pass: resolve forward references left as placeholders at create time (also finishes an interrupted run).
|
|
271
|
+
for (const it of [spikePlan, ...subs]) {
|
|
272
|
+
const rec = records.get(it.key);
|
|
273
|
+
if (!PLACEHOLDER_RE.test(rec.body)) continue;
|
|
274
|
+
const body = finalBody(it);
|
|
275
|
+
if (body === rec.body) continue;
|
|
276
|
+
gh(["issue", "edit", String(rec.number), "-R", repo, "--body-file", "-"], body);
|
|
277
|
+
rec.body = body;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Board: Status=Todo, Priority, Component (best effort per field; skipped when the board is not configured).
|
|
281
|
+
if (board.project_number && board.project_id) {
|
|
282
|
+
const owner = board.owner || config.project?.owner || repo.split("/")[0];
|
|
283
|
+
const fields = board.field_ids ?? {}, options = board.option_ids ?? {};
|
|
284
|
+
const components = Array.isArray(board.components) ? board.components : [];
|
|
285
|
+
for (const it of [spikePlan, ...subs]) {
|
|
286
|
+
const rec = records.get(it.key);
|
|
287
|
+
const itemId = gh(["project", "item-add", String(board.project_number), "--owner", owner, "--url", rec.url, "--format", "json", "--jq", ".id"]).trim();
|
|
288
|
+
if (!itemId) { notes.push(`#${rec.number}: board item id not returned; fields not set`); continue; }
|
|
289
|
+
const prio = String(it.priority ?? "").toLowerCase();
|
|
290
|
+
const comp = components.find((c) => it.component && String(c.label ?? "").toLowerCase() === String(it.component).toLowerCase())
|
|
291
|
+
?? components.find((c) => c.repo === repo);
|
|
292
|
+
const sets = [[fields.status, options.status?.todo], [fields.priority, options.priority?.[prio]], [fields.component, comp?.option_id]];
|
|
293
|
+
for (const [field, option] of sets) {
|
|
294
|
+
if (!field || !option) continue;
|
|
295
|
+
try {
|
|
296
|
+
gh(["project", "item-edit", "--project-id", board.project_id, "--id", itemId, "--field-id", field, "--single-select-option-id", option]);
|
|
297
|
+
} catch (e) { notes.push(`#${rec.number}: board field ${field} not set (${String(e?.message ?? e).slice(0, 120)})`); }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
notes.push("project_board not configured in forge.yaml (project_number/project_id): board sync skipped");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Spike comments, posted once: a trusted marker comment already present is reused.
|
|
305
|
+
const comments = gh(["api", "--paginate", `repos/${repo}/issues/${spikeNo}/comments`]);
|
|
306
|
+
const trusted = (re) => io.sh("bash", [`${SELF_DIR}trusted-comments.sh`, "count", re], comments).trim() !== "0";
|
|
307
|
+
const attribution = String(config.branding?.show_attribution ?? "true") !== "false";
|
|
308
|
+
if (!trusted("^<!-- FORGE:BOOTSTRAP_PLAN -->")) gh(["issue", "comment", String(spikeNo), "-R", repo, "--body-file", "-"], bootstrapPlanComment(plan));
|
|
309
|
+
if (!trusted("^<!-- FORGE:DECOMPOSED -->")) gh(["issue", "comment", String(spikeNo), "-R", repo, "--body-file", "-"], decomposedComment(plan, numbers, { attribution }));
|
|
310
|
+
if (!records.get(spikeKey).labels.includes("workflow:decomposed")) {
|
|
311
|
+
io.sh("bash", [`${SELF_DIR}transition-label.sh`, String(spikeNo), "-R", repo, "decomposed"]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const spikeRec = records.get(spikeKey);
|
|
315
|
+
return {
|
|
316
|
+
repo,
|
|
317
|
+
spike: { key: spikeKey, number: spikeNo, title: spikePlan.title, url: spikeRec.url, reused: spikeRec.reused },
|
|
318
|
+
issues: subs.map((it) => ({ key: it.key, number: numbers.get(it.key), title: it.title, url: records.get(it.key).url,
|
|
319
|
+
needsHuman: isNeedsHuman(it), milestone: milestoneTitle.get(it.milestone), reused: records.get(it.key).reused })),
|
|
320
|
+
milestones,
|
|
321
|
+
notes,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Plan + spike-doc preflight shared by the CLI and tests: [] when creation may proceed. */
|
|
326
|
+
export function preflight(plan, spikeText) {
|
|
327
|
+
const errors = [];
|
|
328
|
+
const v = validatePlan(plan);
|
|
329
|
+
if (!v.ok) errors.push(formatErrors(v.errors));
|
|
330
|
+
else if (!plan.issues.some((it) => it.key === (plan.spike ?? "SPIKE"))) errors.push(`(plan): [invalid-plan] no spike issue with key ${plan.spike ?? "SPIKE"}`);
|
|
331
|
+
if (spikeText == null) errors.push(`(spike): [missing-spike-doc] ${plan?.spikeDoc ?? DEFAULT_SPIKE_DOC} not found`);
|
|
332
|
+
else {
|
|
333
|
+
const s = checkSpikeDoc(spikeText);
|
|
334
|
+
if (s.missing.length) errors.push(`(spike): [missing-section] ${s.missing.join(", ")}`);
|
|
335
|
+
if (s.noRationale.length) errors.push(`(spike): [missing-rationale] no **Rationale**: line in ${s.noRationale.join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
return errors;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const defaultIo = {
|
|
341
|
+
gh: (args, input) => execFileSync("gh", args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 64 << 20 }),
|
|
342
|
+
sh: (cmd, args, input) => execFileSync(cmd, args, { encoding: "utf8", input, stdio: ["pipe", "pipe", "inherit"] }),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
function readConfig(path) {
|
|
346
|
+
if (!existsSync(path)) return {};
|
|
347
|
+
try { return JSON.parse(execFileSync("yq", ["-o=json", ".", path], { encoding: "utf8" })) ?? {}; } catch { return {}; }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function main(argv, { io = defaultIo, out = process.stdout, err = process.stderr } = {}) {
|
|
351
|
+
let planPath = null, dryRun = false, asJson = false, repo = "", configPath = process.env.FORGE_CONFIG || "forge.yaml", base = "", costPerIssueUsd;
|
|
352
|
+
for (let i = 0; i < argv.length; i++) {
|
|
353
|
+
const a = argv[i];
|
|
354
|
+
const val = () => { if (i + 1 >= argv.length) throw new Error(`${a} requires a value`); return argv[++i]; };
|
|
355
|
+
try {
|
|
356
|
+
if (a === "--dry-run") dryRun = true;
|
|
357
|
+
else if (a === "--json") asJson = true;
|
|
358
|
+
else if (a === "--repo") repo = val();
|
|
359
|
+
else if (a === "--config") configPath = val();
|
|
360
|
+
else if (a === "--base") base = val();
|
|
361
|
+
else if (a === "--cost-per-issue") {
|
|
362
|
+
costPerIssueUsd = Number(val());
|
|
363
|
+
if (!Number.isFinite(costPerIssueUsd) || costPerIssueUsd <= 0) throw new Error("--cost-per-issue must be a positive number");
|
|
364
|
+
}
|
|
365
|
+
else if (!planPath && !a.startsWith("--")) planPath = a;
|
|
366
|
+
else throw new Error(`unknown argument ${a}`);
|
|
367
|
+
} catch (e) { err.write(`bootstrap-create: ${e.message}\n`); return 2; }
|
|
368
|
+
}
|
|
369
|
+
if (!planPath) { err.write("usage: node scripts/bootstrap-create.mjs <plan.json> [--dry-run] [--json] [--repo OWNER/NAME] [--config forge.yaml] [--base <branch>] [--cost-per-issue <usd>]\n"); return 2; }
|
|
370
|
+
let plan;
|
|
371
|
+
try { plan = JSON.parse(readFileSync(planPath, "utf8")); } catch (e) { err.write(`bootstrap-create: cannot read plan: ${e.message}\n`); return 2; }
|
|
372
|
+
const spikePath = plan?.spikeDoc ?? DEFAULT_SPIKE_DOC;
|
|
373
|
+
const spikeText = existsSync(spikePath) ? readFileSync(spikePath, "utf8") : null;
|
|
374
|
+
const errors = preflight(plan, spikeText);
|
|
375
|
+
if (errors.length) { err.write(errors.join("\n") + "\nbootstrap-create: not creating anything; fix the plan or spike doc\n"); return 1; }
|
|
376
|
+
if (dryRun) { out.write(planPreview(plan, { costPerIssueUsd }) + "\n"); return 0; }
|
|
377
|
+
|
|
378
|
+
const config = readConfig(configPath);
|
|
379
|
+
repo ||= config.project?.owner && config.project?.repo ? `${config.project.owner}/${config.project.repo}` : "";
|
|
380
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) { err.write("bootstrap-create: no target repository: pass --repo OWNER/NAME or a forge.yaml with project.owner/project.repo\n"); return 2; }
|
|
381
|
+
let result;
|
|
382
|
+
try { result = createBacklog(plan, { io, repo, config, base }); } catch (e) {
|
|
383
|
+
err.write(`bootstrap-create: GitHub error: ${String(e?.stderr || e?.message || e).trim().slice(0, 500)}\nRe-run the same command to resume; existing issues are reused.\n`);
|
|
384
|
+
return 3;
|
|
385
|
+
}
|
|
386
|
+
out.write((asJson ? JSON.stringify(result, null, 2) : handoffText(result)) + "\n");
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
391
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
392
|
+
process.exitCode = main(process.argv.slice(2));
|
|
393
|
+
}
|