@skitterbyte/skitterspec-linear 10.6.0 → 10.8.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/assets/claude-md-section.md +19 -9
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/core/ci-stages.md +110 -0
- package/assets/core/env.config.md +18 -0
- package/assets/core/linear.config.json.example +4 -0
- package/assets/core/linear.config.md +88 -0
- package/assets/rules/commit-trailers.md +37 -5
- package/assets/rules/spec-planning.md +19 -4
- package/assets/skills/spec/SKILL.md +20 -0
- package/assets/skills/spec-bug/SKILL.md +48 -2
- package/assets/skills/spec-cancel/SKILL.md +9 -0
- package/assets/skills/spec-complete/SKILL.md +12 -1
- package/assets/skills/spec-go/SKILL.md +10 -8
- package/assets/skills/spec-hotfix/SKILL.md +49 -3
- package/assets/skills/spec-linear-setup/SKILL.md +38 -2
- package/assets/skills/spec-status/SKILL.md +1 -0
- package/assets/skills/spec-sync/SKILL.md +9 -0
- package/assets/skills/spec-to-main/SKILL.md +2 -1
- package/bin/skitterspec-linear.js +10 -0
- package/package.json +1 -1
- package/src/cli.js +211 -44
- package/src/env/config.js +19 -0
- package/src/env/teardown.js +62 -4
- package/src/init.js +120 -2
- package/src/vendor/linear/cli-sync.js +454 -34
- package/src/vendor/linear/config.js +158 -1
- package/src/vendor/linear/doctor.js +67 -1
- package/src/vendor/linear/released.js +149 -5
- package/src/vendor/sync-core/index.js +4 -1
- package/src/vendor/sync-core/src/compare.js +59 -3
- package/src/vendor/sync-core/src/normalize.js +65 -2
- package/assets/skills/spec-connect/SKILL.md +0 -59
- package/assets/skills/spec-live/SKILL.md +0 -73
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* intake: { label, bugLabels, hotfixLabels },
|
|
21
21
|
* mapping: { specFolder, phases, tasks },
|
|
22
22
|
* states: { backlog, "in-progress", complete, cancelled },
|
|
23
|
+
* release: { stages: [{ key, state }] },
|
|
23
24
|
* snapshot: { overviewFile },
|
|
24
25
|
* branch: { pattern },
|
|
25
26
|
* sync: {
|
|
@@ -78,6 +79,36 @@ const TRANSPORTS = Object.freeze(['api', 'mcp'])
|
|
|
78
79
|
// nothing secret is ever written to the repo.
|
|
79
80
|
const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
|
|
80
81
|
|
|
82
|
+
// The project's OWN deployment ladder: where a ticket goes AFTER its spec is
|
|
83
|
+
// complete — deployed to test, approved for demo, live in prod. Deliberately an
|
|
84
|
+
// open, ORDERED list in the project's vocabulary rather than a fixed set: one
|
|
85
|
+
// team's `On Test`/`Ready for Demo` is another's `staging`, and most projects
|
|
86
|
+
// have none at all. Empty (the default) means the project declared no ladder,
|
|
87
|
+
// and every stage-aware path is simply unused.
|
|
88
|
+
//
|
|
89
|
+
// It is NOT keyed by lifecycle bucket, unlike `states` and `mapping.phases`.
|
|
90
|
+
// A deployment stage is a fact about an ENVIRONMENT, and no folder under
|
|
91
|
+
// `specs/` can ever derive one — which is why it must not join
|
|
92
|
+
// LIFECYCLE_BUCKETS.
|
|
93
|
+
const DEFAULT_RELEASE_STAGES = Object.freeze([])
|
|
94
|
+
|
|
95
|
+
// Repo-relative path prefixes whose commits are BOOKKEEPING, not shipped work.
|
|
96
|
+
//
|
|
97
|
+
// A spec's `chore(spec): complete <name>` commit carries the same `Refs:`
|
|
98
|
+
// trailer as the code it describes, but lands AFTER the tag that shipped that
|
|
99
|
+
// code — so without this the ticket appears in two consecutive release ranges:
|
|
100
|
+
// once for its code, once for its paperwork. Measured on one consumer, 14 of the
|
|
101
|
+
// 22 ref-carrying commits in 300 were spec bookkeeping, so this is the dominant
|
|
102
|
+
// case rather than an edge one.
|
|
103
|
+
//
|
|
104
|
+
// PATHS, not commit subjects. `chore(spec):` is a convention a mislabelled
|
|
105
|
+
// commit escapes; what a commit changed is a fact. A commit touching an ignored
|
|
106
|
+
// path AND a source file still counts — it shipped code.
|
|
107
|
+
//
|
|
108
|
+
// An explicit `[]` is the opt-out, and a project that keeps its paperwork
|
|
109
|
+
// elsewhere names its own directories here.
|
|
110
|
+
const DEFAULT_RELEASE_IGNORE_PATHS = Object.freeze(['specs/'])
|
|
111
|
+
|
|
81
112
|
const DEFAULT_CONFIG = Object.freeze({
|
|
82
113
|
// `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
|
|
83
114
|
// first `/spec-push` offer the team's projects and pre-select this one; empty
|
|
@@ -111,6 +142,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
111
142
|
cancelled: 'Canceled',
|
|
112
143
|
}),
|
|
113
144
|
snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
|
|
145
|
+
// See DEFAULT_RELEASE_STAGES above. `stages` is ordered: the order is recorded
|
|
146
|
+
// for reporting and doctor's ladder check, and deliberately NOT enforced — a
|
|
147
|
+
// rollback from test and a hotfix going straight to prod are both legitimate.
|
|
148
|
+
// `ignorePaths` (see DEFAULT_RELEASE_IGNORE_PATHS) is what `released`/`stage`
|
|
149
|
+
// treat as bookkeeping rather than shipped work.
|
|
150
|
+
release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES, ignorePaths: DEFAULT_RELEASE_IGNORE_PATHS }),
|
|
114
151
|
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
115
152
|
// `keyEnv` names the env var holding the personal API key. It is a NAME, not a
|
|
116
153
|
// key: putting the secret itself here would commit it.
|
|
@@ -162,6 +199,10 @@ function defaults() {
|
|
|
162
199
|
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
163
200
|
states: { ...DEFAULT_CONFIG.states },
|
|
164
201
|
snapshot: { ...DEFAULT_CONFIG.snapshot },
|
|
202
|
+
release: {
|
|
203
|
+
stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })),
|
|
204
|
+
ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
|
|
205
|
+
},
|
|
165
206
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
166
207
|
auth: { ...DEFAULT_CONFIG.auth },
|
|
167
208
|
apply: { ...DEFAULT_CONFIG.apply },
|
|
@@ -249,6 +290,84 @@ function mergePhaseMapping(base, parsed) {
|
|
|
249
290
|
}
|
|
250
291
|
}
|
|
251
292
|
|
|
293
|
+
// Merge (and validate) `release.stages` — the project's deployment ladder.
|
|
294
|
+
//
|
|
295
|
+
// Loud on a malformed entry, like fieldOwnership and mapping.phases above: this
|
|
296
|
+
// list names Linear states, and Linear SILENTLY IGNORES an unknown state. A
|
|
297
|
+
// half-typed ladder would push clean and move nothing, which is exactly the
|
|
298
|
+
// failure `validateStates` exists to stop — so a bad shape fails here, at load,
|
|
299
|
+
// rather than at the first deploy nobody is watching.
|
|
300
|
+
//
|
|
301
|
+
// An ABSENT `release` block is not an error: it is the opt-out, and the whole
|
|
302
|
+
// feature is unused without it.
|
|
303
|
+
function mergeReleaseStages(base, parsed) {
|
|
304
|
+
const value = parsed.stages
|
|
305
|
+
if (value === undefined) return
|
|
306
|
+
if (!Array.isArray(value)) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Invalid ${CONFIG_FILE}: release.stages = ${JSON.stringify(value)} ` +
|
|
309
|
+
'(expected an array of { key, state })',
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
const stages = []
|
|
313
|
+
const seen = new Set()
|
|
314
|
+
value.forEach((entry, i) => {
|
|
315
|
+
if (!isObject(entry)) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Invalid ${CONFIG_FILE}: release.stages[${i}] = ${JSON.stringify(entry)} ` +
|
|
318
|
+
'(expected { key, state })',
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
for (const field of ['key', 'state']) {
|
|
322
|
+
if (typeof entry[field] !== 'string' || !entry[field].trim()) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`Invalid ${CONFIG_FILE}: release.stages[${i}].${field} = ${JSON.stringify(entry[field])} ` +
|
|
325
|
+
'(expected a non-empty string)',
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const key = entry.key.trim()
|
|
330
|
+
if (seen.has(key)) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Invalid ${CONFIG_FILE}: release.stages[${i}].key = ${JSON.stringify(key)} is a duplicate ` +
|
|
333
|
+
'(a stage key is how CI names the rung, so it must be unique)',
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
seen.add(key)
|
|
337
|
+
stages.push({ key, state: entry.state.trim() })
|
|
338
|
+
})
|
|
339
|
+
base.stages = stages
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Merge (and validate) release.ignorePaths — the path prefixes whose commits are
|
|
343
|
+
// bookkeeping. Loud on anything but an array of non-empty strings, like
|
|
344
|
+
// release.stages above: a bad value that quietly fell back to the default would
|
|
345
|
+
// let a project believe it had opted out and go on double-counting tickets.
|
|
346
|
+
//
|
|
347
|
+
// A BLANK entry is rejected rather than dropped. `""` is a prefix of every path,
|
|
348
|
+
// so a stray empty string would silently ignore every commit in the range and
|
|
349
|
+
// report a release as containing nothing — the loudest possible wrong answer,
|
|
350
|
+
// arriving as silence.
|
|
351
|
+
function mergeReleaseIgnorePaths(base, parsed) {
|
|
352
|
+
const value = parsed.ignorePaths
|
|
353
|
+
if (value === undefined) return
|
|
354
|
+
if (!Array.isArray(value)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Invalid ${CONFIG_FILE}: release.ignorePaths = ${JSON.stringify(value)} ` +
|
|
357
|
+
'(expected an array of repo-relative path prefixes)',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
value.forEach((entry, i) => {
|
|
361
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Invalid ${CONFIG_FILE}: release.ignorePaths[${i}] = ${JSON.stringify(entry)} ` +
|
|
364
|
+
'(expected a non-empty repo-relative path prefix, e.g. "specs/")',
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
base.ignorePaths = stringList(value)
|
|
369
|
+
}
|
|
370
|
+
|
|
252
371
|
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
253
372
|
// name (a non-empty string); a field listed here is compared per item.
|
|
254
373
|
function mergeKeyedFields(base, parsed) {
|
|
@@ -312,6 +431,11 @@ function mergeConfig(base, parsed) {
|
|
|
312
431
|
assign(base.snapshot, parsed.snapshot, 'overviewFile', 'string')
|
|
313
432
|
}
|
|
314
433
|
|
|
434
|
+
if (isObject(parsed.release)) {
|
|
435
|
+
mergeReleaseStages(base.release, parsed.release)
|
|
436
|
+
mergeReleaseIgnorePaths(base.release, parsed.release)
|
|
437
|
+
}
|
|
438
|
+
|
|
315
439
|
if (isObject(parsed.branch)) {
|
|
316
440
|
assign(base.branch, parsed.branch, 'pattern', 'string')
|
|
317
441
|
}
|
|
@@ -350,7 +474,8 @@ function mergeConfig(base, parsed) {
|
|
|
350
474
|
* Returns `{ config, present }`:
|
|
351
475
|
* - missing file → `{ config: defaults, present: false }` (opt-out; never throws)
|
|
352
476
|
* - present → `{ config: merged, present: true }`
|
|
353
|
-
* Malformed JSON
|
|
477
|
+
* Malformed JSON, a bad `fieldOwnership` enum, or a malformed `release.stages`
|
|
478
|
+
* entry → throws a clear Error.
|
|
354
479
|
*/
|
|
355
480
|
function loadLinearConfig(dir = process.cwd()) {
|
|
356
481
|
const base = defaults()
|
|
@@ -374,8 +499,39 @@ function loadLinearConfig(dir = process.cwd()) {
|
|
|
374
499
|
return { config: mergeConfig(base, parsed), present: true }
|
|
375
500
|
}
|
|
376
501
|
|
|
502
|
+
/**
|
|
503
|
+
* The project's declared deployment ladder, always an array (empty = none
|
|
504
|
+
* declared). Callers read this rather than reaching into the config, so an
|
|
505
|
+
* older config object without a `release` block cannot throw.
|
|
506
|
+
*/
|
|
507
|
+
function releaseStages(config) {
|
|
508
|
+
const stages = config && config.release && config.release.stages
|
|
509
|
+
return Array.isArray(stages) ? stages : []
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The ladder rung with this key, or null. */
|
|
513
|
+
function stageFor(config, key) {
|
|
514
|
+
return releaseStages(config).find((s) => s.key === key) || null
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The path prefixes whose commits are bookkeeping, always an array.
|
|
519
|
+
*
|
|
520
|
+
* A config object that predates the field gets the DEFAULT — the list
|
|
521
|
+
* `loadLinearConfig` would have produced — rather than an empty one, so an older
|
|
522
|
+
* object cannot quietly turn the filter off. An explicit `[]` survives
|
|
523
|
+
* `Array.isArray` and is honoured as the opt-out.
|
|
524
|
+
*/
|
|
525
|
+
function releaseIgnorePaths(config) {
|
|
526
|
+
const paths = config && config.release && config.release.ignorePaths
|
|
527
|
+
return Array.isArray(paths) ? paths : [...DEFAULT_RELEASE_IGNORE_PATHS]
|
|
528
|
+
}
|
|
529
|
+
|
|
377
530
|
module.exports = {
|
|
378
531
|
loadLinearConfig,
|
|
532
|
+
releaseStages,
|
|
533
|
+
releaseIgnorePaths,
|
|
534
|
+
stageFor,
|
|
379
535
|
mergeConfig,
|
|
380
536
|
defaults,
|
|
381
537
|
DEFAULT_CONFIG,
|
|
@@ -386,4 +542,5 @@ module.exports = {
|
|
|
386
542
|
LIFECYCLE_BUCKETS,
|
|
387
543
|
TRANSPORTS,
|
|
388
544
|
DEFAULT_KEY_ENV,
|
|
545
|
+
DEFAULT_RELEASE_IGNORE_PATHS,
|
|
389
546
|
}
|
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
* by construction rather than by convention.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// `warn` sits between `ok` and `broken`: worth a human's attention, but not a
|
|
33
|
+
// failure. A ladder whose last rung never closes an issue is a real problem for
|
|
34
|
+
// most projects and a deliberate choice for some, and only the project knows
|
|
35
|
+
// which — so it is said, and the exit code is left alone.
|
|
36
|
+
const STATES = ['ok', 'missing', 'broken', 'skipped', 'warn']
|
|
33
37
|
|
|
34
38
|
// A row is a check. `fix` is the exact command to run, or null when there is
|
|
35
39
|
// nothing to fix.
|
|
@@ -57,6 +61,7 @@ function runChecks(state = {}) {
|
|
|
57
61
|
projectCheck(state.project, state.tracker, state.remote),
|
|
58
62
|
keyCheck(state.key, state.tracker),
|
|
59
63
|
remoteCheck(state.remote),
|
|
64
|
+
ladderCheck(state.ladder, state.tracker),
|
|
60
65
|
mcpCheck(state.mcp, state.tracker, state.project, state.remote),
|
|
61
66
|
]
|
|
62
67
|
// `missing` is a declined opt-in, so it must not fail the run. Only a
|
|
@@ -64,6 +69,67 @@ function runChecks(state = {}) {
|
|
|
64
69
|
return { ok: !checks.some((c) => c.state === 'broken'), checks }
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The deployment ladder's shape, when one is declared.
|
|
74
|
+
*
|
|
75
|
+
* WHAT COULD FOOL THIS CHECK, and why it is a `warn` rather than a refusal: a
|
|
76
|
+
* workspace can close an issue by automation (a merged PR, a linked release)
|
|
77
|
+
* rather than by the last rung's own type, and a ladder that stops at a
|
|
78
|
+
* `started` state is then perfectly healthy. Only the project knows which it is.
|
|
79
|
+
*
|
|
80
|
+
* It needs the workspace's state TYPES, which only the API transport returns —
|
|
81
|
+
* `--workspace-states` and the MCP path carry names alone. Without them the row
|
|
82
|
+
* is `skipped`: not knowing a type is not evidence of a bad one.
|
|
83
|
+
*/
|
|
84
|
+
function ladderCheck(s = {}, tracker = {}) {
|
|
85
|
+
if (!tracker.present) return row('ladder', 'ladder', 'skipped', 'no tracker configured')
|
|
86
|
+
const stages = Array.isArray(s.stages) ? s.stages : []
|
|
87
|
+
if (!stages.length) {
|
|
88
|
+
return row('ladder', 'ladder', 'skipped', 'no deployment ladder declared')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const summary = stages.map((st) => `${st.key} -> ${st.state}`).join(', ')
|
|
92
|
+
const states = Array.isArray(s.workspaceStates) ? s.workspaceStates : null
|
|
93
|
+
if (!states) {
|
|
94
|
+
return row('ladder', 'ladder', 'skipped', `${stages.length} rung(s), unchecked: ${summary}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const byName = new Map(
|
|
98
|
+
states.filter((st) => st && typeof st.name === 'string').map((st) => [st.name.toLowerCase().trim(), st]),
|
|
99
|
+
)
|
|
100
|
+
// A list that arrived EMPTY, or held nothing with a name, cannot prove a rung
|
|
101
|
+
// is absent — it is a lookup that saw nothing, not a workspace without these
|
|
102
|
+
// states. Accusing every rung off that would be the exact absence-as-evidence
|
|
103
|
+
// mistake the negative-checks rule exists for.
|
|
104
|
+
if (!byName.size) {
|
|
105
|
+
return row('ladder', 'ladder', 'skipped', `${stages.length} rung(s), unchecked: ${summary}`)
|
|
106
|
+
}
|
|
107
|
+
const unknown = stages.filter((st) => !byName.has(String(st.state).toLowerCase().trim()))
|
|
108
|
+
if (unknown.length) {
|
|
109
|
+
return row(
|
|
110
|
+
'ladder',
|
|
111
|
+
'ladder',
|
|
112
|
+
'broken',
|
|
113
|
+
`rung state(s) not in this workspace: ${unknown.map((st) => `${st.key} -> "${st.state}"`).join(', ')}` +
|
|
114
|
+
' — Linear silently ignores an unknown state, so these would move nothing',
|
|
115
|
+
'/spec-linear-setup',
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const last = stages[stages.length - 1]
|
|
120
|
+
const type = (byName.get(String(last.state).toLowerCase().trim()) || {}).type
|
|
121
|
+
if (type !== 'completed') {
|
|
122
|
+
return row(
|
|
123
|
+
'ladder',
|
|
124
|
+
'ladder',
|
|
125
|
+
'warn',
|
|
126
|
+
`ends at "${last.state}" (type: ${type || 'unknown'}) — an issue finishing the ladder never reaches a ` +
|
|
127
|
+
'completed state. Add a final rung, or ignore this if Linear automation closes them.',
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return row('ladder', 'ladder', 'ok', `${stages.length} rung(s): ${summary}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
67
133
|
function scaffoldCheck(s = {}) {
|
|
68
134
|
if (!s.specsDir) {
|
|
69
135
|
return row('scaffold', 'scaffold', 'missing', 'no specs/ folder', 'skitterspec init')
|
|
@@ -51,18 +51,77 @@ function refsInBody(body) {
|
|
|
51
51
|
return found
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether a commit's changed paths are ENTIRELY bookkeeping.
|
|
56
|
+
*
|
|
57
|
+
* The problem it solves: a spec's `chore(spec): complete <name>` commit carries
|
|
58
|
+
* the same `Refs:` trailer as the code it describes, but lands after the tag
|
|
59
|
+
* that shipped that code — so the ticket turns up in two consecutive release
|
|
60
|
+
* ranges, once for its code and once for its paperwork.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately a POSITIVE signal: it says yes only when it actually saw paths
|
|
63
|
+
* and every one of them sits under an ignored prefix. It never reasons from an
|
|
64
|
+
* absence.
|
|
65
|
+
*
|
|
66
|
+
* WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
|
|
67
|
+
* no files at all for a MERGE commit (without `-m`) — verified, not assumed —
|
|
68
|
+
* nor for a genuinely empty one, and a `git log` that failed outright yields no
|
|
69
|
+
* paths for anything. Under that phrasing every one of those becomes a release
|
|
70
|
+
* silently losing its tickets. Under this one they mean "nothing was seen, so
|
|
71
|
+
* nothing is known", which keeps the pre-filter behaviour: an over-claimed
|
|
72
|
+
* ticket is noticed when someone looks for it, whereas a ticket that quietly
|
|
73
|
+
* belongs to no release never is.
|
|
74
|
+
*
|
|
75
|
+
* Matching is path-prefix, not glob: `specs` and `specs/` both mean the
|
|
76
|
+
* directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
|
|
77
|
+
* never swallows `specs-archive/`.
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]|null|undefined} paths repo-relative paths the commit changed
|
|
80
|
+
* @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
|
|
81
|
+
*/
|
|
82
|
+
function onlyIgnoredPaths(paths, ignorePaths) {
|
|
83
|
+
if (!Array.isArray(paths) || !paths.length) return false
|
|
84
|
+
const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
|
|
85
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
86
|
+
.map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
if (!prefixes.length) return false
|
|
89
|
+
return paths.every((raw) => {
|
|
90
|
+
const file = String(raw == null ? '' : raw)
|
|
91
|
+
.trim()
|
|
92
|
+
.replace(/^\.\//, '')
|
|
93
|
+
// An unreadable entry is an unknown, not an ignored one — it makes the whole
|
|
94
|
+
// commit count, per the bias above.
|
|
95
|
+
if (!file) return false
|
|
96
|
+
return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
54
100
|
/**
|
|
55
101
|
* Fold commits into the report a release needs.
|
|
56
102
|
*
|
|
57
|
-
* @param {Array<{sha?:string, subject?:string, body?:string}>} commits
|
|
58
|
-
* @
|
|
103
|
+
* @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
|
|
104
|
+
* @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
|
|
105
|
+
* see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
|
|
106
|
+
* report is what it was before the filter existed.
|
|
107
|
+
* @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
|
|
59
108
|
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
60
|
-
* is listed once, where it first appears, not eight times.
|
|
109
|
+
* is listed once, where it first appears, not eight times. `ignored` is
|
|
110
|
+
* reported rather than merely subtracted — a filter that removes commits in
|
|
111
|
+
* silence reads as "there was nothing there".
|
|
61
112
|
*/
|
|
62
|
-
function ticketsInRange(commits) {
|
|
113
|
+
function ticketsInRange(commits, options = {}) {
|
|
114
|
+
const ignorePaths = (options && options.ignorePaths) || []
|
|
63
115
|
const seen = new Map()
|
|
64
116
|
let unreferenced = 0
|
|
117
|
+
let ignored = 0
|
|
65
118
|
for (const commit of commits || []) {
|
|
119
|
+
if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
|
|
120
|
+
// Not counted as unreferenced either: that number exists to surface a
|
|
121
|
+
// MISSED trailer, and a paperwork commit is not a gap someone should hunt.
|
|
122
|
+
ignored++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
66
125
|
const refs = [...new Set(refsInBody(commit && commit.body))]
|
|
67
126
|
if (!refs.length) {
|
|
68
127
|
unreferenced++
|
|
@@ -73,8 +132,93 @@ function ticketsInRange(commits) {
|
|
|
73
132
|
return {
|
|
74
133
|
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
75
134
|
unreferenced,
|
|
135
|
+
ignored,
|
|
76
136
|
total: (commits || []).length,
|
|
77
137
|
}
|
|
78
138
|
}
|
|
79
139
|
|
|
80
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Split a release's tickets into the ones a stage move may touch and the ones it
|
|
142
|
+
* must not, with a reason for every exclusion.
|
|
143
|
+
*
|
|
144
|
+
* Three reasons to leave a ticket alone, and each is a case where acting would
|
|
145
|
+
* be worse than not acting:
|
|
146
|
+
*
|
|
147
|
+
* - **foreign** — the ref is not this repo's team. A range can carry another
|
|
148
|
+
* team's ref (a shared dependency, a quoted ticket), and writing to a team
|
|
149
|
+
* this repo was never configured for is the worst failure available here.
|
|
150
|
+
* - **unlinked** — the ref is this team's, but no spec in the repo claims it.
|
|
151
|
+
* It may be tracker-only work, or a typo in a trailer. Either way nothing
|
|
152
|
+
* here knows what it is, so it is reported, not moved.
|
|
153
|
+
* - **unfinished** — a spec that has not reached the ceded bucket. Its code
|
|
154
|
+
* really is in the release (it landed via `/spec-to-main`), but push still
|
|
155
|
+
* owns its workflow state and would bounce it straight back. Exactly one
|
|
156
|
+
* writer per issue at any moment beats a visible flip-flop.
|
|
157
|
+
*
|
|
158
|
+
* Pure: it takes the spec list as data, so the rules are testable without a repo.
|
|
159
|
+
*/
|
|
160
|
+
function partitionStageMoves({ tickets, teamKey, specs, cededBucket = 'complete' }) {
|
|
161
|
+
const bucketOf = new Map()
|
|
162
|
+
for (const spec of specs || []) {
|
|
163
|
+
if (spec && spec.identifier) bucketOf.set(spec.identifier, spec.bucket)
|
|
164
|
+
}
|
|
165
|
+
const key = String(teamKey || '').trim().toUpperCase()
|
|
166
|
+
const movable = []
|
|
167
|
+
const foreign = []
|
|
168
|
+
const unlinked = []
|
|
169
|
+
const unfinished = []
|
|
170
|
+
for (const ticket of tickets || []) {
|
|
171
|
+
const ref = ticket && ticket.ref
|
|
172
|
+
if (!ref) continue
|
|
173
|
+
if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
|
|
174
|
+
foreign.push(ticket)
|
|
175
|
+
} else if (!bucketOf.has(ref)) {
|
|
176
|
+
unlinked.push(ticket)
|
|
177
|
+
} else if (bucketOf.get(ref) !== cededBucket) {
|
|
178
|
+
unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
|
|
179
|
+
} else {
|
|
180
|
+
movable.push(ticket)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { movable, foreign, unlinked, unfinished }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether moving from `fromState` to the rung `toKey` runs against the declared
|
|
188
|
+
* order — and if so, how to say it.
|
|
189
|
+
*
|
|
190
|
+
* WARNS, never refuses. A rollback from test and a hotfix going straight to prod
|
|
191
|
+
* are both legitimate, and a check that blocked either would be wrong on healthy
|
|
192
|
+
* input. An issue that is on no rung yet is entering the ladder, which is the
|
|
193
|
+
* normal case and says nothing.
|
|
194
|
+
*
|
|
195
|
+
* `lifecycleStates` is the bucket map's own state names, and a state among them
|
|
196
|
+
* means the issue is at its LIFECYCLE position, not on a rung — even when the
|
|
197
|
+
* two names coincide. They routinely do: `states.complete` and a final `prod`
|
|
198
|
+
* rung are both naturally "Done", and without this a spec that had merely been
|
|
199
|
+
* completed would read as already deployed to prod, so its first real deploy
|
|
200
|
+
* would be warned about as a move backwards. Bucket wins over ladder here for
|
|
201
|
+
* the same reason it wins in `bucketForState`.
|
|
202
|
+
*
|
|
203
|
+
* @returns {string|null}
|
|
204
|
+
*/
|
|
205
|
+
function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
|
|
206
|
+
const list = Array.isArray(stages) ? stages : []
|
|
207
|
+
const to = list.findIndex((s) => s && s.key === toKey)
|
|
208
|
+
if (to < 0) return null
|
|
209
|
+
const want = String(fromState || '').toLowerCase().trim()
|
|
210
|
+
if (!want) return null
|
|
211
|
+
const lifecycle = new Set(
|
|
212
|
+
(Array.isArray(lifecycleStates) ? lifecycleStates : [])
|
|
213
|
+
.filter((n) => typeof n === 'string')
|
|
214
|
+
.map((n) => n.toLowerCase().trim()),
|
|
215
|
+
)
|
|
216
|
+
if (lifecycle.has(want)) return null
|
|
217
|
+
const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
|
|
218
|
+
if (from < 0) return null
|
|
219
|
+
if (to < from) return `moves back from "${list[from].key}"`
|
|
220
|
+
if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* stored matches what was sent — it merges nothing (see `src/verify.js`).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions } = require('./src/normalize.js')
|
|
16
|
+
const { normalizeLocal, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
|
|
17
17
|
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify } = require('./src/compare.js')
|
|
18
18
|
const { readBase, writeBase } = require('./src/base.js')
|
|
19
19
|
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
@@ -39,6 +39,9 @@ module.exports = {
|
|
|
39
39
|
titleFromText,
|
|
40
40
|
validateStates,
|
|
41
41
|
stateSuggestions,
|
|
42
|
+
stageForState,
|
|
43
|
+
remoteStage,
|
|
44
|
+
LADDER_ORIGIN_BUCKET,
|
|
42
45
|
hashField,
|
|
43
46
|
stableStringify,
|
|
44
47
|
readBase,
|
|
@@ -43,9 +43,28 @@ function hashField(value) {
|
|
|
43
43
|
// The spec ISSUE fields the repo owns and pushes: prose + workflow state.
|
|
44
44
|
// Priority, labels, cycles and comments are Linear-native triage — one-way sync
|
|
45
45
|
// neither pushes nor reads them, so a PM's triage is never clobbered.
|
|
46
|
+
//
|
|
47
|
+
// The COMBINED hash, retained for snapshots written before the fields were split
|
|
48
|
+
// (and read by any older CLI still pointed at this repo). New pushes diff
|
|
49
|
+
// `issueFields` below; this stays so neither direction breaks on the other.
|
|
46
50
|
function specIssueHash(p) {
|
|
47
51
|
return hashField({ description: p.description ?? null, state: p.status ?? null })
|
|
48
52
|
}
|
|
53
|
+
|
|
54
|
+
// Per-field hashes of the same two values.
|
|
55
|
+
//
|
|
56
|
+
// They are hashed SEPARATELY because they have different owners once a spec is
|
|
57
|
+
// finished. The repo owns the description forever, but the workflow state is
|
|
58
|
+
// handed off: a deploy pipeline (see `release.stages`) moves the issue past
|
|
59
|
+
// `complete`, and welding the two meant any prose edit re-emitted the state and
|
|
60
|
+
// dragged the issue back. Diffing them apart is what lets a push touch the
|
|
61
|
+
// description without re-asserting a state someone else now owns.
|
|
62
|
+
function specIssueFieldHashes(p) {
|
|
63
|
+
return {
|
|
64
|
+
description: hashField(p.description ?? null),
|
|
65
|
+
state: hashField(p.status ?? null),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
49
68
|
// A phase SUB-ISSUE: its name, goal and state (all repo-owned).
|
|
50
69
|
const subIssueHash = (s) => hashField({ name: s.name ?? null, goal: s.goal ?? null, state: s.state ?? null })
|
|
51
70
|
|
|
@@ -63,7 +82,11 @@ function snapshotOf(projection) {
|
|
|
63
82
|
return out
|
|
64
83
|
}
|
|
65
84
|
return {
|
|
85
|
+
// Both shapes are written: `issueFields` is what a current push diffs, and
|
|
86
|
+
// `issue` keeps a snapshot readable by anything still expecting the combined
|
|
87
|
+
// hash. Cheap insurance — two SHA-1s of text already in hand.
|
|
66
88
|
issue: specIssueHash(p),
|
|
89
|
+
issueFields: specIssueFieldHashes(p),
|
|
67
90
|
subIssues: byId(p.subIssues, subIssueHash),
|
|
68
91
|
}
|
|
69
92
|
}
|
|
@@ -92,12 +115,43 @@ function planChanges(projection, snapshot) {
|
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
const plan = { subIssues }
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
118
|
+
const issue = issueChanges(p, snap)
|
|
119
|
+
if (issue) plan.issue = issue
|
|
98
120
|
return plan
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
/**
|
|
124
|
+
* What changed about the spec issue itself — `{description}`, `{state}`, or
|
|
125
|
+
* both, or null when neither did.
|
|
126
|
+
*
|
|
127
|
+
* Three states, not two: a snapshot may carry the split hashes, only the old
|
|
128
|
+
* combined one, or nothing at all. Only the first can say which field moved.
|
|
129
|
+
*
|
|
130
|
+
* The other two are UNKNOWN, and route to the harmless branch — today's welded
|
|
131
|
+
* behaviour, sending both fields. Sending a state that did not change is
|
|
132
|
+
* redundant; withholding one that did would leave the mirror silently stale, and
|
|
133
|
+
* that is the failure worth avoiding. The push rewrites the snapshot in the new
|
|
134
|
+
* shape, so a spec passes through `unknown` exactly once.
|
|
135
|
+
*/
|
|
136
|
+
function issueChanges(projection, snapshot) {
|
|
137
|
+
const p = projection || {}
|
|
138
|
+
const snap = snapshot || {}
|
|
139
|
+
const both = () => ({ description: p.description ?? null, state: p.status ?? null })
|
|
140
|
+
|
|
141
|
+
const fields = snap.issueFields
|
|
142
|
+
if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
|
|
143
|
+
// No split hashes recorded: an old snapshot, or no snapshot at all (a
|
|
144
|
+
// create, which needs both fields anyway).
|
|
145
|
+
return snap.issue === specIssueHash(p) ? null : both()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const want = specIssueFieldHashes(p)
|
|
149
|
+
const changed = {}
|
|
150
|
+
if (fields.description !== want.description) changed.description = p.description ?? null
|
|
151
|
+
if (fields.state !== want.state) changed.state = p.status ?? null
|
|
152
|
+
return Object.keys(changed).length ? changed : null
|
|
153
|
+
}
|
|
154
|
+
|
|
101
155
|
// True when a plan would push nothing.
|
|
102
156
|
function isEmptyPlan(plan) {
|
|
103
157
|
return !plan.issue && !plan.subIssues.create.length && !plan.subIssues.update.length
|
|
@@ -105,6 +159,8 @@ function isEmptyPlan(plan) {
|
|
|
105
159
|
|
|
106
160
|
module.exports = {
|
|
107
161
|
planChanges,
|
|
162
|
+
issueChanges,
|
|
163
|
+
specIssueFieldHashes,
|
|
108
164
|
snapshotOf,
|
|
109
165
|
isEmptyPlan,
|
|
110
166
|
hashField,
|