Repository navigation
Expand file tree
/
Copy pathopenspec-tracking.cjs
More file actions
449 lines (422 loc) · 17.7 KB
/
Copy pathopenspec-tracking.cjs
File metadata and controls
449 lines (422 loc) · 17.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
// SPDX-License-Identifier: MIT
"use strict";
/**
* OpenSpec tracking planner: decide which tracking issues to open, reopen,
* escalate, or close for unarchived changes under `openspec/changes/`.
*
* WHY THIS EXISTS. An unarchived change directory on `main` is not a chore left
* undone. It means `openspec/specs/` is currently wrong: some of the
* requirements written there are already met by shipped code, and the standing
* spec still describes the world before it.
*
* Three earlier attempts reported that through a check status and all three
* were deleted in 8d1f3a1. A pull-request gate had to infer stack position; a
* step in `Validate` ran `main` red for as long as a forward-merging stack took
* to drain; a daily sweep split changes into DONE and STALE to guess whether
* unfinished work was abandoned or merely slow. The predicates were roughly
* right. The channel was not: a signal expected to be red is not a signal.
*
* So nothing here fails a run. The output is a plan, and the workflow executes
* it against the issues API.
*
* WHAT DECIDES. In `push` mode, a change is a fault when it is unarchived AND
* no open pull request's diff touches its directory. That claim test is what
* keeps a draining stack quiet: measured on the stack that landed 2026-09-04,
* pull requests 265, 266 and 267 touched 6, 2 and 6 files under
* `openspec/changes/`, because every slice ticks its own boxes in `tasks.md`,
* which lives in the change directory. It is a file-path question about open
* pull requests, not a reconstruction of stack lineage.
*
* In `sweep` mode the claim test is not consulted. Age stands alone, read from
* the last git activity in the change directory, so an active stack keeps
* resetting its own clock and only stalled work crosses the window.
*
* NO `tasks.md` PARSING, DELIBERATELY. There is no parked state on `main`: a
* change is finished or it has not landed. Measured over the archive, task
* checkboxes are a discipline signal rather than a completion signal, with 5 of
* 55 archived changes carrying 1 to 3 unchecked stragglers out of 22 to 37
* tasks. Reading them re-introduces the intent-guessing that got the previous
* sweep deleted.
*
* THE PLAN CARRIES ITS OWN PROSE. Titles, bodies and comments are rendered
* here rather than in the workflow. Shell heredocs nested inside a YAML block
* scalar inside a loop are the wrong place to compose multi-paragraph text, and
* rendering here means the wording is covered by the tests beside this file.
*
* Usage:
* node .github/scripts/openspec-tracking.cjs < input.json
* node .github/scripts/openspec-tracking.cjs --list [changesDirectory]
*
* The second form is the one the `openspec-list` composite action
* (`.github/actions/openspec-list/run.sh`) calls on behalf of three workflows
* to list unarchived change directories: it prints `listUnarchivedChanges()`
* as a sorted JSON array of names on stdout, and nothing else. That routes
* the listing through `readdirSync`, which behaves the same on every platform
* `node` runs on, rather than through a shell `find`, where `-printf` is a
* GNU extension that a checkout under BSD find (macOS) does not recognise. A
* missing changes directory prints `[]` and exits zero; an unreadable one
* throws and exits non-zero, and the action turns that into an `ok=false`
* output that the calling workflow reports as a `::warning::` annotation
* rather than either an empty list or a failed run.
*
* Reads one JSON object on stdin and prints the plan as JSON on stdout:
*
* {
* "mode": "push" | "sweep",
* "sha": "abc1234",
* "staleDays": 7,
* "unarchived": [{ "name": "some-change", "ageDays": 3 }],
* "pulls": [{ "number": 265, "files": ["openspec/changes/some-change/tasks.md"] }],
* "issues": [{ "number": 12, "state": "open", "body": "<!-- openspec-tracking:some-change -->", "idleDays": 9 }]
* }
*
* `claims` may be supplied directly instead of `pulls` when the caller has
* already reduced them, and an issue may carry `change` directly instead of a
* `body` to parse. `idleDays` is how long the issue has gone without activity,
* and throttles every sweep report on an existing issue, both an escalation
* comment and a reopen, to at most one per window.
*
* Always exits zero on a well-formed input. A malformed input is a defect in
* the caller and exits non-zero, which is the one case where silence would hide
* that the check never ran.
*/
const { readdirSync, existsSync, readFileSync } = require("node:fs");
const ARCHIVE_DIRECTORY = "archive";
const DEFAULT_STALE_DAYS = 7;
/**
* The marker carried in an issue body so the workflow can find that issue again
* without matching on its title. A title is editable by anyone reading the
* issue, and a retitled issue would be invisible, which produces a second issue
* for the same change rather than an update to the first.
*/
function marker(change) {
return `<!-- openspec-tracking:${change} -->`;
}
/**
* Read the marker back out of an issue body. Returns undefined when the body
* carries none, so an unrelated issue can never be adopted and then closed by
* this workflow.
*/
function changeFromBody(body) {
const found = /<!-- openspec-tracking:([^\s>]+) -->/.exec(body ?? "");
return found === null ? undefined : found[1];
}
/**
* Every directory under `openspec/changes/` other than `archive/`. A missing
* changes directory is not a fault: a repository with no OpenSpec work in
* flight is the state this whole mechanism exists to reach.
*/
function listUnarchivedChanges(changesDirectory) {
if (!existsSync(changesDirectory)) {
return [];
}
return readdirSync(changesDirectory, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== ARCHIVE_DIRECTORY)
.map((entry) => entry.name)
.sort();
}
/**
* Which changes a set of pull requests claims. `files` is the paths a pull
* request's diff against `main` touches; touching anything under a change's
* directory claims it.
*/
function claimsFromPullRequests(pullRequests) {
const claims = {};
for (const pullRequest of pullRequests) {
for (const path of pullRequest.files ?? []) {
const found = /^openspec\/changes\/([^/]+)\//.exec(path);
if (found === null || found[1] === ARCHIVE_DIRECTORY) {
continue;
}
const change = found[1];
claims[change] = claims[change] ?? [];
if (!claims[change].includes(pullRequest.number)) {
claims[change].push(pullRequest.number);
}
}
}
return claims;
}
function issueTitle(change) {
return `OpenSpec: ${change} is unarchived on main`;
}
function issueBody(change, sha) {
return [
marker(change),
"",
`\`openspec/changes/${change}/\` is on \`main\` and is not archived, and no open`,
"pull request is still working on it.",
"",
"**The standing specs are wrong right now.** Some of the requirements in",
"`openspec/specs/` are already met by code that shipped, and the spec still",
"describes the world before it. Archiving is what reconciles them.",
"",
"```bash",
`pnpm openspec archive ${change}`,
"```",
"",
"Before archiving, read the pre-archive check in `CLAUDE.md`. A",
"`## MODIFIED Requirements` block replaces a standing requirement wholesale and",
"silently deletes any scenario it leaves out.",
"",
`Last seen on \`${sha ?? "an unrecorded commit"}\`. This issue closes itself when the`,
"change reaches `openspec/changes/archive/`.",
].join("\n");
}
function reopenComment(change, sha) {
return `Seen again on \`${sha ?? "an unrecorded commit"}\`: \`${change}\` is unarchived on \`main\` with no open pull request working on it.`;
}
/**
* The sweep's reopen text, separate from `reopenComment` because the two modes
* reopen for different reasons and the reason is the whole content of the
* comment. Push mode reopens on the claim test, which the sweep does not
* consult at all, so reusing that wording would state something the sweep never
* measured.
*/
function sweepReopenComment(change, ageDays, staleDays) {
return [
`Reopened. \`openspec/changes/${change}/\` is still on \`main\` and has had no git`,
`activity for ${ageDays} days, past the ${staleDays}-day window.`,
"",
"Closing this issue does not archive the change. While the change directory",
"is still there, `openspec/specs/` describes requirements the shipped code",
"has already met, so the scheduled sweep reopens the issue rather than",
"commenting into a closed thread nobody is watching.",
].join("\n");
}
function escalateComment(change, ageDays, staleDays) {
return [
`Still unarchived. \`openspec/changes/${change}/\` has had no git activity for ${ageDays} days,`,
`past the ${staleDays}-day window.`,
"",
"The scheduled sweep reports this on age alone. There is no parked state on",
"`main`, so an unfinished change sitting here is the same fault as a finished",
"one: the standing specs describe requirements the shipped code has already met.",
].join("\n");
}
function closeComment(action, sha) {
if (action.reason === "archived") {
return `Archived. \`${action.change}\` reached \`openspec/changes/archive/\` on \`${sha ?? "an unrecorded commit"}\`, so the standing specs and the shipped code agree again.`;
}
const claimants = (action.claimants ?? []).join(", ");
return `Claimed again on \`${sha ?? "an unrecorded commit"}\` by pull request(s) ${claimants}. Closing, because the change is no longer unclaimed. The next push after that work lands re-evaluates.`;
}
/**
* Read the tracking issues into the shape the planner matches on.
*
* The marker is parsed HERE and nowhere else. Both workflows used to
* re-implement this regex as a `jq capture(...)`, which put three copies of one
* format string in three files with nothing keeping them in sync, and `capture`
* drops a non-matching element from the array rather than erroring, so an issue
* whose body stopped matching would read as "no issue exists" and produce a
* duplicate instead of a failure.
*
* An issue with no marker is not ours and is ignored. That is the same outcome
* `jq` produced, but it is now a decision made in one place with a test on it.
*/
function normalizeIssues(issues) {
return issues
.map((issue) => ({
number: issue.number,
state: String(issue.state ?? "open").toLowerCase(),
change: issue.change ?? changeFromBody(issue.body),
idleDays: issue.idleDays,
}))
.filter((issue) => issue.change !== undefined);
}
function findIssue(issues, change) {
return issues.find((issue) => issue.change === change);
}
/**
* The plan. Each action names one change and one thing to do to its issue, and
* carries the text the workflow needs to send.
*
* `close` carries a reason because the two cases read differently to whoever
* finds the issue later: `archived` is the success path, and `claimed` means
* work resumed on a change that had been reported as abandoned.
*/
function planActions(input) {
const mode = input.mode;
if (mode !== "push" && mode !== "sweep") {
throw new Error(`unknown mode: ${String(mode)}`);
}
const sha = input.sha;
const staleDays = input.staleDays ?? DEFAULT_STALE_DAYS;
const unarchived = input.unarchived ?? [];
const claims =
input.claims ?? claimsFromPullRequests(input.pulls ?? []);
const issues = normalizeIssues(input.issues ?? []);
const actions = [];
const seen = new Set();
for (const change of unarchived) {
const name = change.name;
seen.add(name);
const issue = findIssue(issues, name);
const claimants = claims[name] ?? [];
if (mode === "sweep") {
// Age stands alone here. An active stack touches its own change
// directory on every slice, so its age never reaches the window.
const ageDays = change.ageDays ?? 0;
if (ageDays < staleDays) {
continue;
}
if (issue === undefined) {
actions.push({
type: "open",
change: name,
title: issueTitle(name),
body: issueBody(name, sha),
});
continue;
}
// Escalate at most once per window. `ageDays` only grows, so without this
// every daily run past the window appends another near-identical comment
// forever, and a notification a day is not a durable signal. Any activity
// on the issue also defers the next one: a thread someone is already
// working in does not need the bot restating the age.
//
// The throttle sits ABOVE the closed-issue branch below so it governs a
// reopen too. A reopen is louder than a comment, and reopening on every
// daily run is the same nag this throttle exists to prevent.
if ((issue.idleDays ?? Number.POSITIVE_INFINITY) < staleDays) {
continue;
}
if (issue.state === "closed") {
// A closed issue used to fall straight through to `escalate` here,
// because `findIssue` runs over a `--state all` listing so
// `issue !== undefined` is satisfied by a closed issue just as well as
// by an open one. The push branch tests this and the sweep branch did
// not, so someone closing a tracking issue without archiving the change
// got a daily comment appended to a closed thread forever: green,
// running, and seen by nobody. Only a push to `main` recovered it, via
// the push-mode reopen that this workflow exists to back up for the
// case where the push-time check never ran.
actions.push({
type: "reopen",
change: name,
issue: issue.number,
ageDays,
comment: sweepReopenComment(name, ageDays, staleDays),
});
continue;
}
actions.push({
type: "escalate",
change: name,
issue: issue.number,
ageDays,
comment: escalateComment(name, ageDays, staleDays),
});
continue;
}
if (claimants.length > 0) {
// Claimed again. An issue opened while nothing claimed the change is now
// wrong, so it closes rather than sitting open against active work.
if (issue !== undefined && issue.state === "open") {
const action = {
type: "close",
reason: "claimed",
change: name,
issue: issue.number,
claimants,
};
actions.push({ ...action, comment: closeComment(action, sha) });
}
continue;
}
if (issue === undefined) {
actions.push({
type: "open",
change: name,
title: issueTitle(name),
body: issueBody(name, sha),
});
} else if (issue.state === "closed") {
actions.push({
type: "reopen",
change: name,
issue: issue.number,
comment: reopenComment(name, sha),
});
}
}
// An open issue for a change that is no longer under `openspec/changes/` has
// been archived. This is the success path and the only way an issue closes
// without a human.
//
// PRECONDITION: `unarchived` is a listing that actually succeeded. Absence
// from it is read as "archived", so an EMPTY array means "every change is
// archived", never "the listing could not be read". A caller that cannot
// enumerate the directory must skip the run rather than pass `[]`: doing the
// latter closes every open tracking issue with "Archived. <change> reached
// openspec/changes/archive/", which is false. Each workflow gates its apply
// step on the listing having succeeded, and openspec-sweep.yml gated only on
// the node exit status: it captured the listing with `2>&1` and read it with
// `done < <(jq ...)`, so a node that wrote one line to stderr while exiting
// zero corrupted the JSON without tripping the guard, `jq` failed inside a
// process substitution whose status the loop discards, and `[]` was passed
// here. Measured: a startup `(node:1) Warning:` line yields zero loop
// iterations, exit 0, and `close:archived` for every open issue. Both halves
// are fixed there now, and this note is why they must stay fixed.
for (const issue of issues) {
if (issue.state === "open" && !seen.has(issue.change)) {
const action = {
type: "close",
reason: "archived",
change: issue.change,
issue: issue.number,
};
actions.push({ ...action, comment: closeComment(action, sha) });
}
}
return { mode, staleDays, actions };
}
function main(raw) {
const input = JSON.parse(raw ?? readFileSync(0, "utf8"));
return planActions(input);
}
const DEFAULT_CHANGES_DIRECTORY = "openspec/changes";
/**
* `--list` mode. Prints `listUnarchivedChanges(changesDirectory)` as a JSON
* array on stdout, and nothing else, so a caller can pipe stdout straight
* into `jq` without stripping any other output.
*
* A missing directory is not a fault (see `listUnarchivedChanges`) and prints
* `[]`. An unreadable one throws out of `readdirSync`, which this
* deliberately does not catch: letting it propagate is what turns it into a
* non-zero exit for `require.main` to report, which is the signal the
* `openspec-list` action needs to tell "nothing to report" apart from "could
* not tell".
*/
function runList(changesDirectory) {
process.stdout.write(
`${JSON.stringify(listUnarchivedChanges(changesDirectory))}\n`
);
}
module.exports = {
ARCHIVE_DIRECTORY,
DEFAULT_STALE_DAYS,
marker,
changeFromBody,
listUnarchivedChanges,
claimsFromPullRequests,
normalizeIssues,
issueTitle,
issueBody,
planActions,
main,
runList,
};
if (require.main === module) {
try {
const args = process.argv.slice(2);
if (args[0] === "--list") {
runList(args[1] ?? DEFAULT_CHANGES_DIRECTORY);
} else {
process.stdout.write(`${JSON.stringify(main(), null, 2)}\n`);
}
} catch (error) {
console.error(`openspec-tracking failed: ${error.message}`);
process.exitCode = 1;
}
}