@connorbritain/roadmap 0.3.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 +495 -0
- package/docs/DEPLOYMENT.md +195 -0
- package/package.json +46 -0
- package/schema/backlog.schema.json +65 -0
- package/schema/roadmap.schema.json +273 -0
- package/scripts/assistant.mjs +30 -0
- package/scripts/backlog.mjs +81 -0
- package/scripts/cleanup.mjs +69 -0
- package/scripts/cli.mjs +119 -0
- package/scripts/cycle.mjs +89 -0
- package/scripts/dispatch.mjs +302 -0
- package/scripts/estimate.mjs +201 -0
- package/scripts/fanout.mjs +355 -0
- package/scripts/grab.mjs +119 -0
- package/scripts/init.mjs +60 -0
- package/scripts/lib/assistant-core.mjs +64 -0
- package/scripts/lib/backlog-core.mjs +318 -0
- package/scripts/lib/brief.mjs +123 -0
- package/scripts/lib/cli-core.mjs +97 -0
- package/scripts/lib/cycle-core.mjs +58 -0
- package/scripts/lib/estimate-core.mjs +204 -0
- package/scripts/lib/execution.mjs +186 -0
- package/scripts/lib/fanout-core.mjs +39 -0
- package/scripts/lib/graph.mjs +346 -0
- package/scripts/lib/journal-core.mjs +48 -0
- package/scripts/lib/linear-core.mjs +812 -0
- package/scripts/lib/mcp-core.mjs +313 -0
- package/scripts/lib/plan.mjs +68 -0
- package/scripts/lib/plate-core.mjs +53 -0
- package/scripts/lib/pr-watch-core.mjs +72 -0
- package/scripts/lib/priority.mjs +43 -0
- package/scripts/lib/recommend.mjs +178 -0
- package/scripts/lib/render-core.mjs +215 -0
- package/scripts/lib/review-core.mjs +104 -0
- package/scripts/lib/store.mjs +113 -0
- package/scripts/lib/sync-core.mjs +89 -0
- package/scripts/lib/validate-core.mjs +130 -0
- package/scripts/lib/wizard-core.mjs +50 -0
- package/scripts/linear.mjs +780 -0
- package/scripts/mcp.mjs +193 -0
- package/scripts/next.mjs +43 -0
- package/scripts/plate.mjs +77 -0
- package/scripts/promote.mjs +27 -0
- package/scripts/prompt.mjs +124 -0
- package/scripts/render.mjs +39 -0
- package/scripts/review.mjs +79 -0
- package/scripts/scheduler.mjs +78 -0
- package/scripts/set.mjs +31 -0
- package/scripts/show.mjs +53 -0
- package/scripts/test/run.mjs +3943 -0
- package/scripts/validate.mjs +28 -0
- package/scripts/watch-prs.mjs +72 -0
- package/scripts/wizard.mjs +97 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roadmap linear <status|auth|setup|sync> — the ONLY file that talks to Linear's API.
|
|
3
|
+
// The brain is lib/linear-core.mjs (pure); this layer does GraphQL IO (global fetch,
|
|
4
|
+
// injectable for tests), the sync cursor, and the YAML write-backs via lib/store.mjs.
|
|
5
|
+
//
|
|
6
|
+
// roadmap linear status [--probe] [--json] state check (probe = one networked viewer query)
|
|
7
|
+
// roadmap linear auth how to set LINEAR_API_KEY (never stored in files)
|
|
8
|
+
// roadmap linear setup --team KEY [...] write meta.linear (queries your teams first)
|
|
9
|
+
// roadmap linear provision shape the workspace: labels, views, guidance texts
|
|
10
|
+
// roadmap linear sync [--dry] [--push-only] [--pull-only]
|
|
11
|
+
// roadmap linear post-update --pi <id> --body <text|@file> digest → Linear project update
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { loadGraph, flatten } from "./lib/graph.mjs";
|
|
18
|
+
import { setFields } from "./lib/mcp-core.mjs";
|
|
19
|
+
import { addItem, setItemFields } from "./lib/backlog-core.mjs";
|
|
20
|
+
import { mutateRoadmap, mutateBacklog, loadBacklog, roadmapPaths } from "./lib/store.mjs";
|
|
21
|
+
import { plateDrainKeys, setPlateDoc } from "./lib/plate-core.mjs";
|
|
22
|
+
import { noteBody } from "./lib/journal-core.mjs";
|
|
23
|
+
import {
|
|
24
|
+
normalizeLinearConfig, linearState, linearStatusLine, buildPushPlan, buildPullProposals, holdsFor,
|
|
25
|
+
provisionPlan, manualViewChecklist, agentGuidanceText, dispatchGuidance, initiativePlan, initiativeStyle,
|
|
26
|
+
startStampTargets, milestonePlan, cyclePlan, cycleCandidates, CYCLE_STATUSES, staleKeys,
|
|
27
|
+
} from "./lib/linear-core.mjs";
|
|
28
|
+
|
|
29
|
+
const ENDPOINT = "https://api.linear.app/graphql";
|
|
30
|
+
const CURSOR_FILE = ".roadmap-linear-state.json";
|
|
31
|
+
|
|
32
|
+
// ── transport (injectable; deliberately NOT exported — every consumer goes through the
|
|
33
|
+
// run* operations so this stays the only file that talks to the API) ──────────────────
|
|
34
|
+
async function gql(query, variables, { apiKey, fetchImpl = fetch }) {
|
|
35
|
+
const res = await fetchImpl(ENDPOINT, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey }, // personal key: bare, no Bearer
|
|
38
|
+
body: JSON.stringify({ query, variables }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) throw new Error(`Linear API HTTP ${res.status}${res.status === 401 ? " — is LINEAR_API_KEY valid?" : ""}`);
|
|
41
|
+
const body = await res.json();
|
|
42
|
+
if (body.errors && body.errors.length) throw new Error(`Linear API: ${body.errors.map((e) => e.message).join("; ")}`);
|
|
43
|
+
return body.data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── cursor ────────────────────────────────────────────────────────────────────
|
|
47
|
+
export function readCursor(root) {
|
|
48
|
+
try { return JSON.parse(readFileSync(join(root, CURSOR_FILE), "utf8")); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
// Patch-merge: lastSync (pull window) and stale (the election CLI's offline basis) advance
|
|
51
|
+
// independently — persisting one never clobbers the other.
|
|
52
|
+
function writeCursor(root, patch) {
|
|
53
|
+
const cur = readCursor(root) || { version: 1 };
|
|
54
|
+
writeFileSync(join(root, CURSOR_FILE), JSON.stringify({ ...cur, ...patch }, null, 2) + "\n", "utf8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── queries ───────────────────────────────────────────────────────────────────
|
|
58
|
+
// withCycle: only the cycles feature asks for activeCycle — with cycles off the query is
|
|
59
|
+
// byte-identical to the pre-cycles tool (the off-path contract every knob keeps).
|
|
60
|
+
async function fetchTeamBundle(teamKey, io, withCycle = false) {
|
|
61
|
+
const data = await gql(
|
|
62
|
+
`query($key: String!) { teams(filter: { key: { eq: $key } }) { nodes {
|
|
63
|
+
id key name
|
|
64
|
+
${withCycle ? "activeCycle { id }\n " : ""}states { nodes { id name type position } }
|
|
65
|
+
labels { nodes { id name } } } } }`,
|
|
66
|
+
{ key: teamKey }, io);
|
|
67
|
+
const team = data.teams.nodes[0];
|
|
68
|
+
if (!team) throw new Error(`no Linear team with key "${teamKey}" (check meta.linear.team)`);
|
|
69
|
+
return {
|
|
70
|
+
id: team.id,
|
|
71
|
+
activeCycleId: (withCycle && team.activeCycle && team.activeCycle.id) || null, // null: cycles off in Linear, or between cycles
|
|
72
|
+
states: [...team.states.nodes].sort((a, b) => a.position - b.position),
|
|
73
|
+
labels: Object.fromEntries(((team.labels && team.labels.nodes) || []).map((l) => [l.name, l.id])),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The one issueUpdate literal — four call sites (push, milestone attach, cycle assign/clear)
|
|
78
|
+
// share the identical mutation shape.
|
|
79
|
+
const updateIssue = (id, input, io) =>
|
|
80
|
+
gql(`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { id } } }`, { id, input }, io);
|
|
81
|
+
|
|
82
|
+
// Project drift snapshot, keyed by the mapped project ids only (NOT all team projects). `content`
|
|
83
|
+
// is a heavy rich-text field — fetching it for every project inside the team bundle blows Linear's
|
|
84
|
+
// 10k query-complexity ceiling, so it lives here, batched small, exactly like fetchIssueSnapshot.
|
|
85
|
+
const mappedProjectIds = (graph) => (graph.pis || []).map((p) => p.linear && p.linear.project).filter(Boolean);
|
|
86
|
+
async function fetchProjectSnapshot(ids, io) {
|
|
87
|
+
const projects = {};
|
|
88
|
+
for (let i = 0; i < ids.length; i += 10) {
|
|
89
|
+
const chunk = ids.slice(i, i + 10);
|
|
90
|
+
const q = `query { ${chunk.map((id, j) => `p${j}: project(id: "${id}") { id name description content color icon priority startDate targetDate status { id } }`).join(" ")} }`;
|
|
91
|
+
const data = await gql(q, {}, io);
|
|
92
|
+
chunk.forEach((_, j) => {
|
|
93
|
+
const p = data[`p${j}`];
|
|
94
|
+
if (p) projects[p.id] = { id: p.id, name: p.name, description: p.description || "", content: p.content || "",
|
|
95
|
+
color: p.color || "", icon: p.icon || "", priority: p.priority || 0, startDate: p.startDate || null, targetDate: p.targetDate || null,
|
|
96
|
+
statusId: p.status ? p.status.id : null };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return projects;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Workspace project-status inventory (live-verified location: organization.projectStatuses).
|
|
103
|
+
// A failure degrades to null — projects simply keep their current Linear status this sync —
|
|
104
|
+
// recorded on the result by the caller, never fatal.
|
|
105
|
+
async function fetchProjectStatuses(io) {
|
|
106
|
+
const data = await gql(`query { organization { projectStatuses { id name type position } } }`, {}, io);
|
|
107
|
+
return [...data.organization.projectStatuses].sort((a, b) => a.position - b.position);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Snapshot of our mapped issues, batched via aliases (identifiers are valid issue(id:) args).
|
|
111
|
+
async function fetchIssueSnapshot(identifiers, io) {
|
|
112
|
+
const issues = {};
|
|
113
|
+
for (let i = 0; i < identifiers.length; i += 50) {
|
|
114
|
+
const chunk = identifiers.slice(i, i + 50);
|
|
115
|
+
const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { id identifier title description priority estimate state { id } project { id } assignee { id } labels { nodes { id } } }`).join(" ")} }`;
|
|
116
|
+
const data = await gql(q, {}, io);
|
|
117
|
+
chunk.forEach((_, j) => {
|
|
118
|
+
const iss = data[`i${j}`];
|
|
119
|
+
if (iss) issues[iss.identifier] = { id: iss.id, title: iss.title, description: iss.description || "", priority: iss.priority, estimate: iss.estimate ?? null, stateId: iss.state.id,
|
|
120
|
+
projectId: iss.project ? iss.project.id : null,
|
|
121
|
+
assigneeId: iss.assignee ? iss.assignee.id : null,
|
|
122
|
+
labelIds: ((iss.labels && iss.labels.nodes) || []).map((l) => l.id) };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return issues;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function fetchInbound(cfg, since, io) {
|
|
129
|
+
const sources = [{ team: cfg.team, project: null }, ...cfg.watch];
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const src of sources) {
|
|
132
|
+
const filter = { team: { key: { eq: src.team } }, ...(since ? { updatedAt: { gt: since } } : {}), ...(src.project ? { project: { name: { eq: src.project } } } : {}) };
|
|
133
|
+
// ponytail: 100 issues per source per sync — cursor-windowed, so backpressure self-heals next run.
|
|
134
|
+
const data = await gql(
|
|
135
|
+
`query($filter: IssueFilter) { issues(filter: $filter, first: 100) { nodes {
|
|
136
|
+
identifier title priority updatedAt state { name type } team { key } project { name } } } }`,
|
|
137
|
+
{ filter }, io);
|
|
138
|
+
for (const n of data.issues.nodes) {
|
|
139
|
+
out.push({ identifier: n.identifier, title: n.title, priority: n.priority,
|
|
140
|
+
state: n.state, team: n.team.key, project: n.project ? n.project.name : null, updatedAt: n.updatedAt });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── the one sync implementation (CLI + MCP both call this) ───────────────────
|
|
147
|
+
export async function runSync(root, opts = {}) {
|
|
148
|
+
const env = opts.env || process.env;
|
|
149
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
150
|
+
const cfg = normalizeLinearConfig(graph.meta || {});
|
|
151
|
+
const state = linearState({ meta: graph.meta, env, cursor: readCursor(root) });
|
|
152
|
+
if (!state.configured) throw new Error("Linear isn't configured for this roadmap — add meta.linear or run 'roadmap linear setup --team <KEY>'");
|
|
153
|
+
if (!state.authed) throw new Error("Linear is configured but LINEAR_API_KEY isn't set ('roadmap linear auth' explains)");
|
|
154
|
+
const io = { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch };
|
|
155
|
+
const now = opts.now || new Date().toISOString();
|
|
156
|
+
|
|
157
|
+
const backlog = loadBacklog(root);
|
|
158
|
+
const team = await fetchTeamBundle(cfg.team, io, cfg.cycles === "on");
|
|
159
|
+
const mapped = collectIdentifiers(graph, backlog);
|
|
160
|
+
const existing = { issues: await fetchIssueSnapshot(mapped, io), projects: await fetchProjectSnapshot(mappedProjectIds(graph), io) };
|
|
161
|
+
const docsUrl = repoDocsUrl(root, graph);
|
|
162
|
+
|
|
163
|
+
const result = { pushed: [], proposals: null, cursorAdvanced: false, dry: !!opts.dry };
|
|
164
|
+
|
|
165
|
+
// Project-status inventory — lets the push map PI status → project status. Degrades to null
|
|
166
|
+
// (no status projection) so a workspace/API hiccup can never abort the sync.
|
|
167
|
+
let projectStatuses = null;
|
|
168
|
+
try { projectStatuses = await fetchProjectStatuses(io); }
|
|
169
|
+
catch (e) { result.projectStatusError = e.message; }
|
|
170
|
+
|
|
171
|
+
// ── pull FIRST (live-verified ordering): inbound is read before any push executes, so a
|
|
172
|
+
// human's Linear edit can never be clobbered by the projection while it's still an open
|
|
173
|
+
// proposal — push holds those fields until the proposal is resolved. In auto mode the
|
|
174
|
+
// deltas apply to the YAML here, so the subsequent push naturally agrees with them.
|
|
175
|
+
let holds = new Set();
|
|
176
|
+
let inboxEmpty = true;
|
|
177
|
+
if (!opts.pushOnly && cfg.pull !== "off") {
|
|
178
|
+
const inbound = await fetchInbound(cfg, state.lastSync, io);
|
|
179
|
+
const proposals = buildPullProposals({ cfg, inbound, graph, backlog });
|
|
180
|
+
result.proposals = proposals;
|
|
181
|
+
inboxEmpty = !proposals.newItems.length && !proposals.deltas.length;
|
|
182
|
+
if (!opts.dry && cfg.pull === "auto") {
|
|
183
|
+
for (const item of proposals.newItems) mutateBacklog(root, (doc) => addItem(doc, item), { createIfMissing: true });
|
|
184
|
+
for (const d of proposals.deltas) {
|
|
185
|
+
if (d.to == null) continue; // canceled-slice flags stay human decisions even on auto
|
|
186
|
+
const fields = d.field === "status" ? { status: d.to } : { priority: { ...(currentPriority(root, d) || {}), tier: d.to } };
|
|
187
|
+
if (d.kind === "slice") mutateRoadmap(root, (doc) => setFields(doc, { invoke: d.key, fields }));
|
|
188
|
+
else mutateBacklog(root, (doc) => setItemFields(doc, { id: d.key, fields }));
|
|
189
|
+
}
|
|
190
|
+
result.applied = proposals;
|
|
191
|
+
} else {
|
|
192
|
+
holds = holdsFor(proposals.deltas);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Re-read after auto-apply so the push plan reflects the accepted inbound edits.
|
|
196
|
+
let pushGraph = result.applied ? loadGraph(roadmapPaths(root).yaml) : graph;
|
|
197
|
+
const pushBacklog = result.applied ? loadBacklog(root) : backlog;
|
|
198
|
+
|
|
199
|
+
// ── auto-stamp project start dates ── a PI that's active without an explicit start_date gets one (the
|
|
200
|
+
// sync date ≈ when it was picked up), so the Linear roadmap timeline has a start. Explicit wins; stamped
|
|
201
|
+
// once then stable. Write-back BEFORE the push so the new startDate projects this run.
|
|
202
|
+
const toStamp = opts.dry ? [] : startStampTargets(pushGraph); // pure decision (linear-core); IO write below
|
|
203
|
+
if (toStamp.length) {
|
|
204
|
+
const today = now.slice(0, 10);
|
|
205
|
+
mutateRoadmap(root, (doc) => {
|
|
206
|
+
const pis = doc.toJS().pis || [];
|
|
207
|
+
for (const id of toStamp) { const idx = pis.findIndex((p) => p.id === id); if (idx >= 0) doc.setIn(["pis", idx, "start_date"], today); }
|
|
208
|
+
return { startStamped: toStamp.length };
|
|
209
|
+
});
|
|
210
|
+
pushGraph = loadGraph(roadmapPaths(root).yaml);
|
|
211
|
+
result.startStamped = toStamp;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── plate auto-drain (complete-only) ── a finished slice leaves My Issues. Write-back BEFORE the push
|
|
215
|
+
// so the projection unassigns it. Skipped on dry runs (no writes) and when the feature is off.
|
|
216
|
+
if (!opts.dry && Array.isArray(pushGraph.meta && pushGraph.meta.plate) && pushGraph.meta.plate.length) {
|
|
217
|
+
const drop = plateDrainKeys(pushGraph, pushBacklog);
|
|
218
|
+
if (drop.length) {
|
|
219
|
+
const keep = pushGraph.meta.plate.filter((k) => !drop.includes(k));
|
|
220
|
+
mutateRoadmap(root, (doc) => { setPlateDoc(doc, keep); return { plateDrained: drop.length }; });
|
|
221
|
+
pushGraph = loadGraph(roadmapPaths(root).yaml);
|
|
222
|
+
result.plateDrained = drop;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// viewer id — only when the plate feature is on; a fetch failure just disables the assignee projection.
|
|
226
|
+
let viewerId = null;
|
|
227
|
+
if (pushGraph.meta && pushGraph.meta.plate != null) {
|
|
228
|
+
try { viewerId = (await gql(`query { viewer { id } }`, {}, io)).viewer.id; } catch { /* no viewer → no assignee ops */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── staleness basis ── committed work (CYCLE_STATUSES) with journal silence past stale_days
|
|
232
|
+
// gets the stale label via the push's ordinary label set-diff. Advisory: a fetch failure is a
|
|
233
|
+
// note, never a failed sync. The set persists to the cursor so the election CLI reads it offline.
|
|
234
|
+
// Push-adjacent, so pull-only skips it — persisting a stale set the push never applied would
|
|
235
|
+
// let the cursor drift from Linear's real label state.
|
|
236
|
+
let stale = new Set();
|
|
237
|
+
if (cfg.stale_days && !opts.pullOnly) {
|
|
238
|
+
try {
|
|
239
|
+
const committed = cycleCandidates(pushGraph).filter((n) => CYCLE_STATUSES.includes(n.status));
|
|
240
|
+
if (committed.length) {
|
|
241
|
+
const activity = await fetchActivityBasis(committed.map((n) => n.linear), io);
|
|
242
|
+
stale = staleKeys({ graph: pushGraph, cfg, activity, now });
|
|
243
|
+
}
|
|
244
|
+
if (stale.size) result.stale = [...stale].sort();
|
|
245
|
+
if (!opts.dry) writeCursor(root, { stale: [...stale].sort() });
|
|
246
|
+
} catch (e) { result.staleError = e.message; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── push ──
|
|
250
|
+
if (!opts.pullOnly) {
|
|
251
|
+
const { ops, missingLabels, unmatchedPlate } = buildPushPlan({ graph: pushGraph, backlog: pushBacklog, cfg, teamStates: team.states, existing, docsUrl, holds, labels: team.labels, viewerId, projectStatuses, now, stale });
|
|
252
|
+
if (missingLabels.length) result.missingLabels = missingLabels;
|
|
253
|
+
if (unmatchedPlate && unmatchedPlate.length) result.unmatchedPlate = unmatchedPlate;
|
|
254
|
+
if (opts.dry) {
|
|
255
|
+
result.pushPlan = ops;
|
|
256
|
+
} else if (ops.length) {
|
|
257
|
+
const projectIds = projectIdsByPi(pushGraph);
|
|
258
|
+
const writeBacks = { pis: [], sprints: [], items: [] };
|
|
259
|
+
// Unverified-VALUE fields (project icon names, issue completedAt) would abort the whole
|
|
260
|
+
// push on rejection. Shared degrade: retry ONCE without that one field, else rethrow.
|
|
261
|
+
// Icon self-heals on a later sync (it's re-diffed); completedAt is create-only, so its
|
|
262
|
+
// degrade is one-way — the issue stays Done-at-creation-time (acceptable: it's history).
|
|
263
|
+
const isIconErr = (e) => /icon|argument validation/i.test(e.message || "");
|
|
264
|
+
const isCompletedAtErr = (e) => /completedAt|argument validation/i.test(e.message || "");
|
|
265
|
+
// Linear stores issue descriptions as DocumentContent and can refuse the write with
|
|
266
|
+
// "conflict on insert of DocumentContent" (live-caught 2026-07-10 under concurrent
|
|
267
|
+
// syncs). The conflict pins to one issue but aborts every op queued behind it.
|
|
268
|
+
// Description is re-diffed on the next sync, so stripping it self-heals like icon.
|
|
269
|
+
const isDocContentErr = (e) => /DocumentContent/i.test(e.message || "");
|
|
270
|
+
const withFieldStripRetry = async (mk, payload, field, isRetryable) => {
|
|
271
|
+
try { return await mk(payload); }
|
|
272
|
+
catch (e) {
|
|
273
|
+
if (payload[field] == null || !isRetryable(e)) throw e;
|
|
274
|
+
const { [field]: _stripped, ...rest } = payload;
|
|
275
|
+
return mk(rest);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
// finally-flush: if an op throws mid-push, everything Linear already created still gets
|
|
279
|
+
// its id written back — otherwise the next sync would create duplicates for those nodes.
|
|
280
|
+
try {
|
|
281
|
+
for (const op of ops) {
|
|
282
|
+
if (op.op === "createProject") {
|
|
283
|
+
const mk = (payload) => gql(`mutation($input: ProjectCreateInput!) { projectCreate(input: $input) { project { id } } }`,
|
|
284
|
+
{ input: { ...payload, teamIds: [team.id] } }, io); // spread: dropping fields here caused live churn (description)
|
|
285
|
+
const d = await withFieldStripRetry(mk, op.payload, "icon", isIconErr);
|
|
286
|
+
projectIds[op.projectRef] = d.projectCreate.project.id;
|
|
287
|
+
writeBacks.pis.push({ pi: op.writeBack.pi, project: d.projectCreate.project.id });
|
|
288
|
+
} else if (op.op === "updateProject") {
|
|
289
|
+
const mk = (payload) => gql(`mutation($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { project { id } } }`,
|
|
290
|
+
{ id: op.id, input: payload }, io);
|
|
291
|
+
await withFieldStripRetry(mk, op.payload, "icon", isIconErr);
|
|
292
|
+
} else if (op.op === "createIssue") {
|
|
293
|
+
const input = { teamId: team.id, ...op.payload, ...(op.projectRef && projectIds[op.projectRef] ? { projectId: projectIds[op.projectRef] } : {}) };
|
|
294
|
+
const mkIssue = (inp) => gql(`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id identifier } } }`,
|
|
295
|
+
{ input: inp }, io);
|
|
296
|
+
const d = await withFieldStripRetry(mkIssue, input, "completedAt", isCompletedAtErr);
|
|
297
|
+
const identifier = d.issueCreate.issue.identifier;
|
|
298
|
+
if (op.writeBack.kind === "sprint") writeBacks.sprints.push({ invoke: op.writeBack.invoke, identifier });
|
|
299
|
+
else writeBacks.items.push({ id: op.writeBack.id, identifier });
|
|
300
|
+
} else if (op.op === "updateIssue") {
|
|
301
|
+
await withFieldStripRetry((payload) => updateIssue(op.id, payload, io), op.payload, "description", isDocContentErr);
|
|
302
|
+
}
|
|
303
|
+
result.pushed.push(`${op.op}${op.identifier ? ` ${op.identifier}` : op.writeBack ? ` ${op.writeBack.invoke || op.writeBack.id || op.writeBack.pi}` : ""}`);
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
// one write-back batch per file, through the store's validated path
|
|
307
|
+
if (writeBacks.pis.length || writeBacks.sprints.length) {
|
|
308
|
+
mutateRoadmap(root, (doc) => {
|
|
309
|
+
for (const wb of writeBacks.pis) {
|
|
310
|
+
const idx = doc.toJS().pis.findIndex((p) => p.id === wb.pi);
|
|
311
|
+
doc.setIn(["pis", idx, "linear", "project"], wb.project);
|
|
312
|
+
}
|
|
313
|
+
for (const wb of writeBacks.sprints) setFields(doc, { invoke: wb.invoke, fields: { linear: wb.identifier } });
|
|
314
|
+
return { writeBack: writeBacks.pis.length + writeBacks.sprints.length };
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (writeBacks.items.length) {
|
|
318
|
+
mutateBacklog(root, (doc) => {
|
|
319
|
+
for (const wb of writeBacks.items) setItemFields(doc, { id: wb.id, fields: { linear: wb.identifier } });
|
|
320
|
+
return { writeBack: writeBacks.items.length };
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── initiatives ── group projects under their declared Linear initiatives. Runs AFTER the
|
|
328
|
+
// push write-backs so the project ids exist. Best-effort + graceful: a failure (the
|
|
329
|
+
// initiative API is not yet live-verified) records a note and never fails the sync.
|
|
330
|
+
if (!opts.dry && !opts.pullOnly) {
|
|
331
|
+
try { const ir = await syncInitiatives(root, io); if (ir.initiatives.length) result.initiatives = ir; }
|
|
332
|
+
catch (e) { result.initiativesError = e.message; }
|
|
333
|
+
// milestones run AFTER initiatives (both post-push): the project ids exist and issues are mapped.
|
|
334
|
+
try { const mr = await syncMilestones(root, io); if (mr.milestones.length) result.milestones = mr; }
|
|
335
|
+
catch (e) { result.milestonesError = e.message; }
|
|
336
|
+
// cycles run last: the team's ACTIVE cycle mirrors active+next slice status (the elected batch).
|
|
337
|
+
if (cfg.cycles === "on") {
|
|
338
|
+
if (!team.activeCycleId) result.cyclesNote = "cycles on but the team has no active cycle in Linear — check Team Settings → Cycles";
|
|
339
|
+
else {
|
|
340
|
+
try { const cr = await syncCycles(root, io, team.activeCycleId); if (cr.assigned.length || cr.cleared.length) result.cycles = cr; }
|
|
341
|
+
catch (e) { result.cyclesError = e.message; }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── cursor ── advance only when the inbox is handled (auto) or empty — in propose mode a
|
|
347
|
+
// non-empty inbox stays in the window so unhandled proposals reappear rather than vanish.
|
|
348
|
+
if (!opts.dry && !opts.pushOnly) {
|
|
349
|
+
if (cfg.pull === "off" || cfg.pull === "auto" || inboxEmpty) { writeCursor(root, { lastSync: now }); result.cursorAdvanced = true; }
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Ensure each declared initiative exists in Linear, carries its meta.initiatives icon/color, and each
|
|
355
|
+
// mapped PI's project is attached. UNVERIFIED API (initiativeCreate/Update/ToProjectCreate) — the caller
|
|
356
|
+
// catches and degrades. Idempotent: skips existing initiatives, re-applies style only on drift (the fetch
|
|
357
|
+
// reads current icon/color back), and skips already-attached projects.
|
|
358
|
+
export async function syncInitiatives(root, io) {
|
|
359
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
360
|
+
const plan = initiativePlan(graph);
|
|
361
|
+
if (!plan.initiatives.length) return { initiatives: [] };
|
|
362
|
+
const data = await gql(`query { initiatives(first: 250) { nodes { id name icon color projects { nodes { id } } } } }`, {}, io);
|
|
363
|
+
const byName = new Map((data.initiatives.nodes || []).map((i) => [i.name, i]));
|
|
364
|
+
// icon/color are newly-exercised initiative input — degrade like project icons do: a bad name or an
|
|
365
|
+
// unsupported field drops the initiative to unstyled instead of aborting the whole initiative sync.
|
|
366
|
+
const isStyleErr = (e) => /icon|color|argument validation/i.test(e.message || "");
|
|
367
|
+
const created = [], styled = [];
|
|
368
|
+
for (const name of plan.initiatives) {
|
|
369
|
+
const style = initiativeStyle(graph.meta, name);
|
|
370
|
+
if (!byName.has(name)) {
|
|
371
|
+
const full = { name, ...(style.icon ? { icon: style.icon } : {}), ...(style.color ? { color: style.color } : {}) };
|
|
372
|
+
const mk = (input) => gql(`mutation($input: InitiativeCreateInput!) { initiativeCreate(input: $input) { initiative { id name } } }`, { input }, io);
|
|
373
|
+
let d;
|
|
374
|
+
try { d = await mk(full); if (full.icon || full.color) styled.push(name); }
|
|
375
|
+
catch (e) { if ((full.icon || full.color) && isStyleErr(e)) d = await mk({ name }); else throw e; }
|
|
376
|
+
byName.set(name, { ...d.initiativeCreate.initiative, projects: { nodes: [] } });
|
|
377
|
+
created.push(name);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// existing → apply declared style only when it drifts (idempotent via the fetched icon/color)
|
|
381
|
+
const cur = byName.get(name);
|
|
382
|
+
const patch = {};
|
|
383
|
+
if (style.icon && cur.icon !== style.icon) patch.icon = style.icon;
|
|
384
|
+
if (style.color && (cur.color || "") !== style.color) patch.color = style.color;
|
|
385
|
+
if (Object.keys(patch).length) {
|
|
386
|
+
try {
|
|
387
|
+
await gql(`mutation($id: String!, $input: InitiativeUpdateInput!) { initiativeUpdate(id: $id, input: $input) { initiative { id } } }`, { id: cur.id, input: patch }, io);
|
|
388
|
+
styled.push(name);
|
|
389
|
+
} catch (e) { if (!isStyleErr(e)) throw e; } // best-effort: unsupported update → leave unstyled
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const attached = [];
|
|
393
|
+
for (const a of plan.assignments) {
|
|
394
|
+
const pi = (graph.pis || []).find((p) => p.id === a.pi);
|
|
395
|
+
const projectId = pi && pi.linear && pi.linear.project;
|
|
396
|
+
if (!projectId) continue; // PI has no project (empty/skipped) — nothing to group
|
|
397
|
+
const init = byName.get(a.initiative);
|
|
398
|
+
if (((init.projects && init.projects.nodes) || []).some((p) => p.id === projectId)) continue; // already attached
|
|
399
|
+
await gql(`mutation($input: InitiativeToProjectCreateInput!) { initiativeToProjectCreate(input: $input) { success } }`, { input: { initiativeId: init.id, projectId } }, io);
|
|
400
|
+
(init.projects || (init.projects = { nodes: [] })).nodes.push({ id: projectId }); // local dedupe within this run
|
|
401
|
+
attached.push(`${a.pi} → ${a.initiative}`);
|
|
402
|
+
}
|
|
403
|
+
return { initiatives: plan.initiatives, created, styled, attached };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Mapped issues' current milestone, batched (identifier → { id: uuid, milestoneId }) — like fetchIssueSnapshot.
|
|
407
|
+
async function fetchIssueMilestones(identifiers, io) {
|
|
408
|
+
const out = {};
|
|
409
|
+
for (let i = 0; i < identifiers.length; i += 50) {
|
|
410
|
+
const chunk = identifiers.slice(i, i + 50);
|
|
411
|
+
const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { id identifier projectMilestone { id } }`).join(" ")} }`;
|
|
412
|
+
const data = await gql(q, {}, io);
|
|
413
|
+
chunk.forEach((_, j) => { const iss = data[`i${j}`]; if (iss) out[iss.identifier] = { id: iss.id, milestoneId: iss.projectMilestone ? iss.projectMilestone.id : null }; });
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Ensure each PI's declared milestones (sp.milestone) exist on its Linear project and each mapped issue is
|
|
419
|
+
// attached to its milestone. Mirrors syncInitiatives one level down (issues within a project). UNVERIFIED
|
|
420
|
+
// API (projectMilestoneCreate) — the caller catches and degrades. Idempotent: skips existing milestones by
|
|
421
|
+
// name, and re-attaches an issue only when its projectMilestoneId drifts from the target.
|
|
422
|
+
export async function syncMilestones(root, io) {
|
|
423
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
424
|
+
const plan = milestonePlan(graph);
|
|
425
|
+
if (!plan.pis.length) return { milestones: [] };
|
|
426
|
+
const milestones = [], created = [], attached = [];
|
|
427
|
+
for (const p of plan.pis) {
|
|
428
|
+
const pi = (graph.pis || []).find((x) => x.id === p.pi);
|
|
429
|
+
const projectId = pi && pi.linear && pi.linear.project;
|
|
430
|
+
if (!projectId) continue; // PI has no project — nothing to attach milestones to
|
|
431
|
+
const data = await gql(`query { project(id: "${projectId}") { projectMilestones { nodes { id name } } } }`, {}, io);
|
|
432
|
+
const byName = new Map(((data.project && data.project.projectMilestones && data.project.projectMilestones.nodes) || []).map((m) => [m.name, m.id]));
|
|
433
|
+
for (const name of p.milestones) {
|
|
434
|
+
milestones.push(`${p.pi}/${name}`);
|
|
435
|
+
if (byName.has(name)) continue;
|
|
436
|
+
// sortOrder = current milestone count (append after existing), NOT the plan-local index — else a
|
|
437
|
+
// milestone added between two existing ones on a later run would collide with an existing sortOrder.
|
|
438
|
+
const d = await gql(`mutation($input: ProjectMilestoneCreateInput!) { projectMilestoneCreate(input: $input) { projectMilestone { id } } }`,
|
|
439
|
+
{ input: { projectId, name, sortOrder: byName.size } }, io);
|
|
440
|
+
byName.set(name, d.projectMilestoneCreate.projectMilestone.id);
|
|
441
|
+
created.push(`${p.pi}/${name}`);
|
|
442
|
+
}
|
|
443
|
+
const mapped = p.slices.filter((s) => s.linear);
|
|
444
|
+
if (!mapped.length) continue;
|
|
445
|
+
const cur = await fetchIssueMilestones(mapped.map((s) => s.linear), io);
|
|
446
|
+
for (const s of mapped) {
|
|
447
|
+
const targetId = byName.get(s.milestone);
|
|
448
|
+
const c = cur[s.linear];
|
|
449
|
+
if (!c || !targetId || c.milestoneId === targetId) continue; // unmapped-in-snapshot or already attached
|
|
450
|
+
await updateIssue(c.id, { projectMilestoneId: targetId }, io);
|
|
451
|
+
attached.push(`${s.invoke} → ${s.milestone}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { milestones, created, attached };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Mapped issues' current cycle, batched (identifier → { id: uuid, cycleId }) — like fetchIssueMilestones.
|
|
458
|
+
async function fetchIssueCycles(identifiers, io) {
|
|
459
|
+
const out = {};
|
|
460
|
+
for (let i = 0; i < identifiers.length; i += 50) {
|
|
461
|
+
const chunk = identifiers.slice(i, i + 50);
|
|
462
|
+
const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { id identifier cycle { id } }`).join(" ")} }`;
|
|
463
|
+
const data = await gql(q, {}, io);
|
|
464
|
+
chunk.forEach((_, j) => { const iss = data[`i${j}`]; if (iss) out[iss.identifier] = { id: iss.id, cycleId: iss.cycle ? iss.cycle.id : null }; });
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Activity basis for staleness: the newest journal comment's createdAt (max over first 50 —
|
|
470
|
+
// no ordering assumption; same window the journal read uses), falling back to the issue's own
|
|
471
|
+
// createdAt. NEVER issue updatedAt — our own pushes bump that (the stale label would flap).
|
|
472
|
+
async function fetchActivityBasis(identifiers, io) {
|
|
473
|
+
const out = {};
|
|
474
|
+
for (let i = 0; i < identifiers.length; i += 50) {
|
|
475
|
+
const chunk = identifiers.slice(i, i + 50);
|
|
476
|
+
const q = `query { ${chunk.map((id, j) => `i${j}: issue(id: "${id}") { identifier createdAt comments(first: 50) { nodes { createdAt } } }`).join(" ")} }`;
|
|
477
|
+
const data = await gql(q, {}, io);
|
|
478
|
+
chunk.forEach((_, j) => {
|
|
479
|
+
const iss = data[`i${j}`];
|
|
480
|
+
if (!iss) return;
|
|
481
|
+
const stamps = ((iss.comments && iss.comments.nodes) || []).map((c) => c.createdAt).filter(Boolean);
|
|
482
|
+
out[iss.identifier] = stamps.length ? stamps.sort()[stamps.length - 1] : iss.createdAt;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Keep the team's ACTIVE cycle = the projection of active+next (CYCLE_STATUSES). Runs post-push,
|
|
489
|
+
// so issues created this run (fresh write-backs) get their cycle in the same sync. UNVERIFIED
|
|
490
|
+
// mutation surface (issueUpdate cycleId) — the caller catches and degrades like initiatives/
|
|
491
|
+
// milestones. Idempotent: assign only on drift; clear only CURRENT-cycle membership (a human's
|
|
492
|
+
// future-cycle parking is deliberate planning and stays untouched — cyclePlan's contract).
|
|
493
|
+
export async function syncCycles(root, io, activeCycleId) {
|
|
494
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
495
|
+
const candidates = cycleCandidates(graph);
|
|
496
|
+
if (!candidates.length) return { assigned: [], cleared: [] };
|
|
497
|
+
const issues = await fetchIssueCycles(candidates.map((n) => n.linear), io);
|
|
498
|
+
const plan = cyclePlan({ graph, activeCycleId, issues });
|
|
499
|
+
const assigned = [], cleared = [];
|
|
500
|
+
const ops = [...plan.assign.map((a) => ({ ...a, cycleId: activeCycleId })), ...plan.clear.map((c) => ({ ...c, cycleId: null }))];
|
|
501
|
+
for (const o of ops) {
|
|
502
|
+
if (!o.id) continue; // mapped but not in Linear (deleted?) — leave for the human
|
|
503
|
+
await updateIssue(o.id, { cycleId: o.cycleId }, io);
|
|
504
|
+
(o.cycleId === null ? cleared : assigned).push(o.invoke);
|
|
505
|
+
}
|
|
506
|
+
return { assigned, cleared };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── dispatch transport (the only-network-file rule: dispatch.mjs owns the capsule, this
|
|
510
|
+
// owns the wire) — resolve the issue uuid and post the @-mention comment.
|
|
511
|
+
export async function postDispatchComment(identifier, body, io) {
|
|
512
|
+
const d = await gql(`query { issue(id: "${identifier}") { id identifier } }`, {}, io);
|
|
513
|
+
if (!d.issue) throw new Error(`mapped issue ${identifier} not found in Linear (deleted?)`);
|
|
514
|
+
await gql(`mutation($input: CommentCreateInput!) { commentCreate(input: $input) { comment { id } } }`,
|
|
515
|
+
{ input: { issueId: d.issue.id, body } }, io);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── the journal (progress notes on the mapped issue) ──────────────────────────
|
|
519
|
+
// A slice invoke key / backlog id → its mapped Linear issue identifier (null when unmapped). Mirrors
|
|
520
|
+
// runDispatch's resolver; used by note/notes so an agent can journal against the work it's picking up.
|
|
521
|
+
function resolveMapped(graph, root, key) {
|
|
522
|
+
const node = flatten(graph).nodes.find((n) => n.invoke === key);
|
|
523
|
+
if (node) return { identifier: node.linear };
|
|
524
|
+
const it = ((loadBacklog(root) || {}).items || []).find((i) => i.id === key);
|
|
525
|
+
if (it) return { identifier: it.linear };
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
// Load the graph + build the API io, gating on the SAME configured+authed check runSync/runProvision use
|
|
529
|
+
// (one auth-state source, one error wording). Returns { graph, io }.
|
|
530
|
+
function journalContext(root, opts) {
|
|
531
|
+
const env = opts.env || process.env;
|
|
532
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
533
|
+
const st = linearState({ meta: graph.meta, env });
|
|
534
|
+
if (!st.configured || !st.authed) throw new Error(linearStatusLine(st));
|
|
535
|
+
return { graph, io: { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch } };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Post a progress note to a slice/backlog item's mapped issue. Reuses postDispatchComment's transport.
|
|
539
|
+
// Unknown key → error; a known-but-UNMAPPED slice → soft-skip (best-effort; matches the auto-hook, and
|
|
540
|
+
// keeps journaling frictionless for a worker whose slice just isn't on the tracker).
|
|
541
|
+
export async function runNote(root, key, { kind, text }, opts = {}) {
|
|
542
|
+
const { graph, io } = journalContext(root, opts);
|
|
543
|
+
const m = resolveMapped(graph, root, key);
|
|
544
|
+
if (!m) throw new Error(`no slice or backlog item "${key}"`);
|
|
545
|
+
if (!m.identifier) return { key, skipped: "unmapped" };
|
|
546
|
+
await postDispatchComment(m.identifier, noteBody({ kind, text }), io);
|
|
547
|
+
return { key, identifier: m.identifier };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Read the mapped issue's comment stream (chronological), so a resuming agent sees where it left off.
|
|
551
|
+
export async function runNotes(root, key, opts = {}) {
|
|
552
|
+
const { graph, io } = journalContext(root, opts);
|
|
553
|
+
const m = resolveMapped(graph, root, key);
|
|
554
|
+
if (!m) throw new Error(`no slice or backlog item "${key}"`);
|
|
555
|
+
if (!m.identifier) return { key, skipped: "unmapped", notes: [] };
|
|
556
|
+
const d = await gql(`query { issue(id: "${m.identifier}") { comments(first: 50) { nodes { body createdAt user { name } } } } }`, {}, io);
|
|
557
|
+
const nodes = (d.issue && d.issue.comments && d.issue.comments.nodes) || [];
|
|
558
|
+
return { identifier: m.identifier, notes: nodes.map((n) => ({ author: n.user ? n.user.name : "?", createdAt: n.createdAt, body: n.body })) };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// PI digest → a Linear project update (the "where this bet stands" rollup). Degradation-guarded: the
|
|
562
|
+
// projectUpdateCreate shape is unverified, so a rejection is a skip, not a failure.
|
|
563
|
+
export async function runProjectUpdate(root, pi, body, opts = {}) {
|
|
564
|
+
const { graph, io } = journalContext(root, opts);
|
|
565
|
+
const piObj = (graph.pis || []).find((p) => p.id === pi);
|
|
566
|
+
if (!piObj || !piObj.linear || !piObj.linear.project) throw new Error(`PI "${pi}" has no linear.project mapping — push first ('roadmap linear sync')`);
|
|
567
|
+
try {
|
|
568
|
+
await gql(`mutation($input: ProjectUpdateCreateInput!) { projectUpdateCreate(input: $input) { projectUpdate { id } } }`,
|
|
569
|
+
{ input: { projectId: piObj.linear.project, body } }, io);
|
|
570
|
+
return { pi, posted: true };
|
|
571
|
+
} catch (e) {
|
|
572
|
+
return { pi, posted: false, error: e.message };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── provision: shape the workspace (idempotent) ───────────────────────────────
|
|
577
|
+
export async function runProvision(root, opts = {}) {
|
|
578
|
+
const env = opts.env || process.env;
|
|
579
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
580
|
+
const state = linearState({ meta: graph.meta, env });
|
|
581
|
+
if (!state.configured || !state.authed) throw new Error(linearStatusLine(state));
|
|
582
|
+
const io = { apiKey: env.LINEAR_API_KEY, fetchImpl: opts.fetchImpl || fetch };
|
|
583
|
+
const team = await fetchTeamBundle(state.cfg.team, io);
|
|
584
|
+
|
|
585
|
+
const plan = provisionPlan({ graph, teamLabels: team.labels, cfg: state.cfg });
|
|
586
|
+
const result = { labelsCreated: [], labelsExisting: plan.existingLabels, views: [], viewChecklist: null };
|
|
587
|
+
|
|
588
|
+
for (const name of plan.createLabels) {
|
|
589
|
+
try {
|
|
590
|
+
await gql(`mutation($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { issueLabel { id name } } }`,
|
|
591
|
+
{ input: { name, teamId: team.id } }, io);
|
|
592
|
+
result.labelsCreated.push(name);
|
|
593
|
+
} catch (e) {
|
|
594
|
+
if (/already exists|duplicate/i.test(e.message)) result.labelsExisting.push(name); // creation race
|
|
595
|
+
else throw e;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Views (live-verified mutation). Idempotency: skip names that already exist — a re-run
|
|
600
|
+
// must not duplicate the board (live-caught failure mode). Any rejection degrades to the
|
|
601
|
+
// manual checklist (the designed fallback, not a failure).
|
|
602
|
+
let existingViews = new Set();
|
|
603
|
+
try {
|
|
604
|
+
const vd = await gql(`query { customViews(first: 100) { nodes { id name } } }`, {}, io);
|
|
605
|
+
existingViews = new Set(vd.customViews.nodes.map((v) => v.name));
|
|
606
|
+
} catch { /* view listing unavailable → fall through to create-and-let-it-ride */ }
|
|
607
|
+
for (const v of plan.views) {
|
|
608
|
+
if (existingViews.has(v.name)) { result.viewsExisting = [...(result.viewsExisting || []), v.name]; continue; }
|
|
609
|
+
try {
|
|
610
|
+
await gql(`mutation($input: CustomViewCreateInput!) { customViewCreate(input: $input) { customView { id } } }`,
|
|
611
|
+
{ input: { name: v.name, teamId: team.id, description: v.hint } }, io);
|
|
612
|
+
result.views.push(v.name);
|
|
613
|
+
} catch (e) {
|
|
614
|
+
result.viewChecklist = { rejected: e.message, checklist: manualViewChecklist(plan.views.filter((x) => !result.views.includes(x.name) && !existingViews.has(x.name))) };
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function collectIdentifiers(graph, backlog) {
|
|
622
|
+
const ids = [];
|
|
623
|
+
for (const pi of graph.pis || []) for (const sp of pi.sprints || []) if (sp.linear) ids.push(sp.linear);
|
|
624
|
+
for (const it of (backlog && backlog.items) || []) if (it.linear) ids.push(it.linear);
|
|
625
|
+
return ids;
|
|
626
|
+
}
|
|
627
|
+
function projectIdsByPi(graph) {
|
|
628
|
+
const out = {};
|
|
629
|
+
for (const pi of graph.pis || []) if (pi.linear && pi.linear.project) out[pi.id] = pi.linear.project;
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
function currentPriority(root, d) {
|
|
633
|
+
if (d.kind === "slice") {
|
|
634
|
+
for (const pi of loadGraph(roadmapPaths(root).yaml).pis || []) for (const sp of pi.sprints || []) if (sp.invoke === d.key) return sp.priority;
|
|
635
|
+
} else {
|
|
636
|
+
const bl = loadBacklog(root);
|
|
637
|
+
for (const it of (bl && bl.items) || []) if (it.id === d.key) return it.priority;
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
// https://github.com/<owner>/<repo>/blob/<base_branch> — the docs-link base for issue
|
|
642
|
+
// footers. Unknown/non-GitHub remote → null → footers fall back to the relative path.
|
|
643
|
+
export function repoDocsUrl(root, graph) {
|
|
644
|
+
try {
|
|
645
|
+
const r = spawnSync("git", ["remote", "get-url", (graph.meta && graph.meta.remote) || "origin"], { cwd: root, encoding: "utf8" });
|
|
646
|
+
if (r.status !== 0) return null;
|
|
647
|
+
const m = /github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s*$/.exec(r.stdout);
|
|
648
|
+
if (!m) return null;
|
|
649
|
+
return `https://github.com/${m[1]}/${m[2]}/blob/${(graph.meta && graph.meta.base_branch) || "main"}`;
|
|
650
|
+
} catch { return null; }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
654
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
655
|
+
if (isMain) {
|
|
656
|
+
const args = process.argv.slice(2);
|
|
657
|
+
const sub = args[0] && !args[0].startsWith("-") ? args[0] : "status";
|
|
658
|
+
const has = (n) => args.includes(n);
|
|
659
|
+
const val = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
660
|
+
const root = process.cwd();
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
if (sub === "status") {
|
|
664
|
+
const graph = loadGraph(roadmapPaths(root).yaml);
|
|
665
|
+
const st = linearState({ meta: graph.meta, env: process.env, cursor: readCursor(root) });
|
|
666
|
+
if (has("--json")) { console.log(JSON.stringify({ configured: st.configured, authed: st.authed, lastSync: st.lastSync })); process.exit(0); }
|
|
667
|
+
if (!st.configured) { console.log("Linear: not configured (add meta.linear or 'roadmap linear setup --team <KEY>')."); process.exit(0); }
|
|
668
|
+
if (!st.authed) { console.log(`Linear: configured (team ${st.cfg.team}) but unauthed — set LINEAR_API_KEY ('roadmap linear auth' explains).`); process.exit(0); }
|
|
669
|
+
console.log(`Linear: wired (team ${st.cfg.team} · granularity ${st.cfg.granularity} · pull ${st.cfg.pull} · last sync ${st.lastSync || "never"}).`);
|
|
670
|
+
if (has("--probe")) {
|
|
671
|
+
const d = await gql(`query { viewer { name email } }`, {}, { apiKey: process.env.LINEAR_API_KEY });
|
|
672
|
+
console.log(` probe ok — authed as ${d.viewer.name}`);
|
|
673
|
+
}
|
|
674
|
+
} else if (sub === "auth") {
|
|
675
|
+
console.log([
|
|
676
|
+
"Linear auth uses a personal API key in the LINEAR_API_KEY env var — never stored in any file.",
|
|
677
|
+
" 1. Linear → Settings → Security & access → Personal API keys → create one.",
|
|
678
|
+
" 2. PowerShell: [Environment]::SetEnvironmentVariable('LINEAR_API_KEY','<key>','User')",
|
|
679
|
+
" bash/zsh: echo 'export LINEAR_API_KEY=<key>' >> ~/.bashrc",
|
|
680
|
+
" 3. New shell, then: roadmap linear status --probe",
|
|
681
|
+
].join("\n"));
|
|
682
|
+
} else if (sub === "setup") {
|
|
683
|
+
const key = process.env.LINEAR_API_KEY;
|
|
684
|
+
if (!key) { console.error("✗ setup needs auth first — 'roadmap linear auth' explains."); process.exit(1); }
|
|
685
|
+
const teamKey = val("--team");
|
|
686
|
+
if (!teamKey) {
|
|
687
|
+
const d = await gql(`query { teams(first: 50) { nodes { key name } } }`, {}, { apiKey: key });
|
|
688
|
+
console.log("Your Linear teams:");
|
|
689
|
+
for (const t of d.teams.nodes) console.log(` ${t.key} ${t.name}`);
|
|
690
|
+
console.log(`\nPick the push target: roadmap linear setup --team <KEY> [--granularity pis|slices|slices+backlog] [--pull off|propose|auto]`);
|
|
691
|
+
process.exit(0);
|
|
692
|
+
}
|
|
693
|
+
await fetchTeamBundle(teamKey, { apiKey: key }); // validates the key exists before writing
|
|
694
|
+
const cfg = { team: teamKey, granularity: val("--granularity") || "slices", pull: val("--pull") || "propose" };
|
|
695
|
+
mutateRoadmap(root, (doc) => { doc.setIn(["meta", "linear"], doc.createNode(cfg)); return { setup: teamKey }; });
|
|
696
|
+
console.log(`✓ meta.linear written (team ${teamKey}, granularity ${cfg.granularity}, pull ${cfg.pull}).`);
|
|
697
|
+
console.log(` Add to .gitignore: ${CURSOR_FILE}`);
|
|
698
|
+
console.log(` Optional: branch_convention: "{pi}/{linear}-{sprint}" makes Linear auto-link fanout PRs.`);
|
|
699
|
+
console.log(` Then: roadmap linear sync --dry`);
|
|
700
|
+
} else if (sub === "provision") {
|
|
701
|
+
const r = await runProvision(root);
|
|
702
|
+
console.log(`labels: ${r.labelsCreated.length ? `created ${r.labelsCreated.join(", ")}` : "all present"}${r.labelsExisting.length ? ` (existing: ${r.labelsExisting.join(", ")})` : ""}`);
|
|
703
|
+
if (r.views.length) console.log(`views created: ${r.views.join(", ")}`);
|
|
704
|
+
if (r.viewsExisting && r.viewsExisting.length) console.log(`views already present: ${r.viewsExisting.join(", ")}`);
|
|
705
|
+
if (r.viewChecklist) {
|
|
706
|
+
console.log(`customViewCreate rejected (${r.viewChecklist.rejected}) — pending live verification; manual checklist (~60s in Linear):`);
|
|
707
|
+
console.log(r.viewChecklist.checklist);
|
|
708
|
+
}
|
|
709
|
+
console.log(`\n── Workspace agent guidance (paste into Linear's agent-guidance setting) ──\n${agentGuidanceText()}`);
|
|
710
|
+
console.log(`\n── Repo dispatch guidance (paste into CLAUDE.md, AGENTS.md, or a skills.md your dispatch agents read) ──\n${dispatchGuidance()}`);
|
|
711
|
+
} else if (sub === "note") {
|
|
712
|
+
const rest = args.slice(1);
|
|
713
|
+
const positional = [];
|
|
714
|
+
for (let i = 0; i < rest.length; i++) { if (rest[i] === "--kind") { i++; continue; } if (!rest[i].startsWith("-")) positional.push(rest[i]); }
|
|
715
|
+
const [key, text] = positional;
|
|
716
|
+
if (!key || !text) { console.error(`usage: roadmap linear note <slice-or-id> "<text>" [--kind progress|blocker|done]`); process.exit(2); }
|
|
717
|
+
const r = await runNote(root, key, { kind: val("--kind"), text }, {});
|
|
718
|
+
console.log(r.skipped ? `- ${key} isn't tracker-mapped yet — note skipped (run 'roadmap linear sync' to map it).` : `✓ note posted to ${r.identifier} (${key}).`);
|
|
719
|
+
} else if (sub === "notes") {
|
|
720
|
+
const key = args.slice(1).find((a) => !a.startsWith("-"));
|
|
721
|
+
if (!key) { console.error("usage: roadmap linear notes <slice-or-id>"); process.exit(2); }
|
|
722
|
+
const r = await runNotes(root, key, {});
|
|
723
|
+
if (r.skipped) { console.log(`${key} isn't tracker-mapped yet — no notes ('roadmap linear sync' to map it).`); process.exit(0); }
|
|
724
|
+
console.log(`Notes on ${r.identifier} (${key}) — ${r.notes.length}:`);
|
|
725
|
+
if (!r.notes.length) console.log(" (none yet)");
|
|
726
|
+
for (const n of r.notes) console.log(` • ${n.createdAt ? n.createdAt.slice(0, 16).replace("T", " ") : "?"} ${n.author}: ${n.body.replace(/\s+/g, " ").trim().slice(0, 160)}`);
|
|
727
|
+
} else if (sub === "post-update") {
|
|
728
|
+
const pi = val("--pi");
|
|
729
|
+
const bodyArg = val("--body");
|
|
730
|
+
if (!pi || !bodyArg) { console.error("usage: roadmap linear post-update --pi <id> --body <text|@file>"); process.exit(2); }
|
|
731
|
+
const body = bodyArg.startsWith("@") ? readFileSync(bodyArg.slice(1), "utf8") : bodyArg;
|
|
732
|
+
const r = await runProjectUpdate(root, pi, body, {});
|
|
733
|
+
console.log(r.posted ? `✓ posted a project update on ${pi}.` : `projectUpdateCreate rejected (${r.error}) — digest not posted; pending live verification.`);
|
|
734
|
+
} else if (sub === "sync") {
|
|
735
|
+
const r = await runSync(root, { dry: has("--dry"), pushOnly: has("--push-only"), pullOnly: has("--pull-only") });
|
|
736
|
+
if (r.pushPlan) {
|
|
737
|
+
console.log(`push plan (${r.pushPlan.length} op(s), dry):`);
|
|
738
|
+
for (const op of r.pushPlan) console.log(` ${op.op} ${op.identifier || (op.writeBack && (op.writeBack.invoke || op.writeBack.id || op.writeBack.pi)) || ""}${op.payload && op.payload.title ? ` — ${op.payload.title}` : ""}`);
|
|
739
|
+
} else if (r.pushed.length) {
|
|
740
|
+
console.log(`pushed ${r.pushed.length} op(s): ${r.pushed.join(", ")}`);
|
|
741
|
+
} else {
|
|
742
|
+
console.log("push: nothing to do (in sync).");
|
|
743
|
+
}
|
|
744
|
+
if (r.proposals) {
|
|
745
|
+
const { newItems, deltas } = r.proposals;
|
|
746
|
+
if (!newItems.length && !deltas.length) console.log("pull: inbox empty.");
|
|
747
|
+
else if (r.applied) console.log(`pull (auto): captured ${newItems.length} item(s), applied ${deltas.filter((d) => d.to != null).length} delta(s).`);
|
|
748
|
+
else {
|
|
749
|
+
console.log(`pull inbox (${newItems.length} new, ${deltas.length} delta(s)) — proposals only, nothing applied:`);
|
|
750
|
+
for (const it of newItems) console.log(` + ${it.id} (${it.kind}${it.priority ? ` · ${it.priority.tier}` : ""}) — ${it.title} [from ${it.source.linear.team}${it.source.linear.project ? `/${it.source.linear.project}` : ""}]`);
|
|
751
|
+
for (const d of deltas) console.log(` ~ ${d.kind} ${d.key}: ${d.field} ${d.from} → ${d.to ?? `(${d.note})`}`);
|
|
752
|
+
console.log(` Apply keeps via /sync (it walks this inbox), backlog_add/set tools, or 'roadmap backlog add/set'.`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (r.missingLabels && r.missingLabels.length) {
|
|
756
|
+
console.log(`labels missing in team: ${r.missingLabels.join(", ")} — run 'roadmap linear provision' to create them.`);
|
|
757
|
+
}
|
|
758
|
+
if (r.initiatives) console.log(`initiatives: ${r.initiatives.initiatives.length} grouped${r.initiatives.created.length ? ` (created ${r.initiatives.created.join(", ")})` : ""}${r.initiatives.styled && r.initiatives.styled.length ? ` · styled ${r.initiatives.styled.length}` : ""}${r.initiatives.attached.length ? ` · attached ${r.initiatives.attached.length} project(s)` : ""}`);
|
|
759
|
+
if (r.initiativesError) console.log(`initiatives skipped: ${r.initiativesError} — pending live verification of the initiative API.`);
|
|
760
|
+
if (r.projectStatusError) console.log(`project status skipped: ${r.projectStatusError} — projects keep their current Linear status this sync.`);
|
|
761
|
+
if (r.milestones) console.log(`milestones: ${r.milestones.milestones.length} across projects${r.milestones.created.length ? ` (created ${r.milestones.created.length})` : ""}${r.milestones.attached.length ? ` · attached ${r.milestones.attached.length} issue(s)` : ""}`);
|
|
762
|
+
if (r.milestonesError) console.log(`milestones skipped: ${r.milestonesError} — pending live verification of the projectMilestone API.`);
|
|
763
|
+
if (r.cycles) console.log(`cycle: assigned ${r.cycles.assigned.join(", ") || "none"}${r.cycles.cleared.length ? ` · cleared ${r.cycles.cleared.join(", ")}` : ""} — the active cycle mirrors active+next.`);
|
|
764
|
+
if (r.cyclesError) console.log(`cycles skipped: ${r.cyclesError} — pending live verification of the cycle API.`);
|
|
765
|
+
if (r.cyclesNote) console.log(`cycles: ${r.cyclesNote}`);
|
|
766
|
+
if (r.stale && r.stale.length) console.log(`stale: ${r.stale.join(", ")} — committed work past stale_days with no journal note; the election reviews these first.`);
|
|
767
|
+
if (r.staleError) console.log(`staleness skipped: ${r.staleError} — advisory only, the push was unaffected.`);
|
|
768
|
+
if (r.startStamped && r.startStamped.length) console.log(`start dates: stamped ${r.startStamped.length} active PI(s) (${r.startStamped.join(", ")}) — the Linear timeline now has a start.`);
|
|
769
|
+
if (r.plateDrained && r.plateDrained.length) console.log(`plate: drained ${r.plateDrained.length} completed (${r.plateDrained.join(", ")}) — off My Issues.`);
|
|
770
|
+
if (r.unmatchedPlate && r.unmatchedPlate.length) console.log(`plate: ${r.unmatchedPlate.join(", ")} match no slice/backlog item — typo in meta.plate? ('roadmap plate' lists it).`);
|
|
771
|
+
console.log(r.cursorAdvanced ? `cursor advanced.` : `cursor unchanged${r.dry ? " (dry)" : " (inbox pending)"}.`);
|
|
772
|
+
} else {
|
|
773
|
+
console.error(`roadmap linear: unknown subcommand "${sub}" (status | auth | setup | provision | sync | note | notes | post-update)`);
|
|
774
|
+
process.exit(2);
|
|
775
|
+
}
|
|
776
|
+
} catch (e) {
|
|
777
|
+
console.error(`✗ ${e.message}`);
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
780
|
+
}
|