@skitterbyte/skitterspec-linear 10.7.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/commands/spec-live.md +2 -2
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +43 -1
- package/assets/rules/spec-planning.md +2 -1
- package/assets/skills/spec-bug/SKILL.md +32 -0
- package/assets/skills/spec-hotfix/SKILL.md +32 -0
- package/assets/skills/spec-sync/SKILL.md +8 -0
- package/package.json +1 -1
- package/src/cli.js +36 -6
- package/src/vendor/linear/cli-sync.js +53 -2
- package/src/vendor/linear/config.js +69 -2
- package/src/vendor/linear/released.js +65 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Put one spec live on the already-running dev server
|
|
3
|
-
argument-hint: "[
|
|
2
|
+
description: Put one spec live on the already-running dev server — `<spec>` takes it, a bare `take` takes the spec you are on, `main` hands the instance back
|
|
3
|
+
argument-hint: "[<spec> | main | take | release | abort | status]"
|
|
4
4
|
allowed-tools: Bash({{exec}} skitterspec spec-env live:*)
|
|
5
5
|
disable-model-invocation: true
|
|
6
6
|
---
|
|
@@ -88,8 +88,12 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
|
|
|
88
88
|
// The project's OWN deployment ladder — where a ticket goes AFTER its spec is
|
|
89
89
|
// complete. Empty (the default) means no ladder is declared and every
|
|
90
90
|
// stage-aware path is unused. See "The deployment ladder" below.
|
|
91
|
+
//
|
|
92
|
+
// `ignorePaths` is what `released`/`stage` treat as BOOKKEEPING rather than
|
|
93
|
+
// shipped work — see "Bookkeeping commits" below. `[]` opts out.
|
|
91
94
|
"release": {
|
|
92
|
-
"stages": []
|
|
95
|
+
"stages": [],
|
|
96
|
+
"ignorePaths": ["specs/"]
|
|
93
97
|
},
|
|
94
98
|
|
|
95
99
|
// Git branch name derived for a linked spec. Tokens: {type}, {slug},
|
|
@@ -168,6 +172,44 @@ Run `skitterspec spec-sync states` to see the whole configured vocabulary — th
|
|
|
168
172
|
bucket map and the ladder — against what the workspace actually has.
|
|
169
173
|
|
|
170
174
|
|
|
175
|
+
## Bookkeeping commits (`release.ignorePaths`)
|
|
176
|
+
|
|
177
|
+
`spec-sync released` and `spec-sync stage` answer "what did this release
|
|
178
|
+
contain?" from the `Refs:` trailers in the range. Left alone, that over-answers:
|
|
179
|
+
a spec's `chore(spec): complete <name>` commit carries the **same ref** as the
|
|
180
|
+
code it describes, but lands **after** the tag that shipped that code — so the
|
|
181
|
+
ticket turns up in two consecutive release ranges, once for its code and once
|
|
182
|
+
for its paperwork. Downstream, a deployment ladder then drags an issue that had
|
|
183
|
+
already reached the top back down a rung.
|
|
184
|
+
|
|
185
|
+
`release.ignorePaths` names the repo-relative prefixes that are paperwork:
|
|
186
|
+
|
|
187
|
+
```jsonc
|
|
188
|
+
"release": {
|
|
189
|
+
"ignorePaths": ["specs/"]
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- **Paths, not subjects.** `chore(spec):` is a convention a mislabelled commit
|
|
194
|
+
escapes; what a commit changed is a fact. A commit touching an ignored path
|
|
195
|
+
**and** a source file still counts — it shipped code.
|
|
196
|
+
- **Prefixes, not globs.** `specs` and `specs/` both mean the directory, and a
|
|
197
|
+
prefix matches only on a path boundary, so `specs/` never swallows
|
|
198
|
+
`specs-archive/`. Name a single file to ignore just that file.
|
|
199
|
+
- **Only a commit whose paths are ALL ignored is dropped**, and only when git
|
|
200
|
+
actually listed paths for it. A merge commit (for which git lists none) counts,
|
|
201
|
+
as does every commit if the path read fails — a lookup that saw nothing knows
|
|
202
|
+
nothing, and dropping a ticket nobody notices is worse than the double-count
|
|
203
|
+
this is fixing.
|
|
204
|
+
- **Every drop is disclosed.** The report says `N commit(s) ignored as
|
|
205
|
+
bookkeeping`, and `--json` carries `ignored` / `ignoredCommits`.
|
|
206
|
+
- **`[]` is the opt-out**; a blank entry is a hard error, since `""` is a prefix
|
|
207
|
+
of every path and would silently empty the release.
|
|
208
|
+
|
|
209
|
+
Default: `["specs/"]`. A project that keeps its paperwork elsewhere names its own
|
|
210
|
+
directories instead.
|
|
211
|
+
|
|
212
|
+
|
|
171
213
|
## Spec → Issue, phases → sub-issues
|
|
172
214
|
|
|
173
215
|
Push maps the spec's structure to Linear's, keyed by id so it updates rather than
|
|
@@ -55,7 +55,8 @@ your canonical `localhost` ports so you can test it at the normal URL
|
|
|
55
55
|
move, header edits, the code) happens on the spec's branch in the worktree; `main`
|
|
56
56
|
changes only when it merges. Teardown is folded into `/spec-complete` ·
|
|
57
57
|
`/spec-cancel`. Beneath the skills, `skitterspec spec-env
|
|
58
|
-
<up|down|prune|dev|connect|integrate|hotfix>` is the CLI
|
|
58
|
+
<up|down|prune|dev|connect|integrate|hotfix|live|status|resolve>` is the CLI
|
|
59
|
+
engine (omit the spec name and it uses the worktree you are standing in). Teardown drops
|
|
59
60
|
the finished spec's own test-DB volume; `spec-env prune` additionally reaps
|
|
60
61
|
**orphaned** volumes left by declined/aborted teardowns, so `/spec-complete` and
|
|
61
62
|
`/spec-cancel` also sweep orphans (confirm-first). A **hotfix** is the one
|
|
@@ -312,6 +312,38 @@ and **never auto-push git** — Linear's own automation reacts to real branch/PR
|
|
|
312
312
|
events later. Report the Linear issue URL as part of the skill's finish-up
|
|
313
313
|
message.
|
|
314
314
|
|
|
315
|
+
### Picking the Linear Project
|
|
316
|
+
|
|
317
|
+
Run this **only when minting a spec issue** — creating it for the first time. On
|
|
318
|
+
an update the issue already has a project (or deliberately has none), and that
|
|
319
|
+
placement is **Linear's to own**: never send `project` on an update, and never
|
|
320
|
+
record the choice in the spec file or the snapshot. A PM re-homing a spec issue
|
|
321
|
+
must not show up as drift or be overwritten on the next push.
|
|
322
|
+
|
|
323
|
+
1. **List the candidates.** Ask the engine:
|
|
324
|
+
`skitterspec spec-sync projects --json`. On the API path it returns the team's
|
|
325
|
+
projects; on the MCP path it says so, and you call the discovered project-list
|
|
326
|
+
tool instead. Drop archived / completed projects — they can't take new work.
|
|
327
|
+
2. **Offer them.** Show the names (most recently updated first is fine), plus an
|
|
328
|
+
explicit **None (team only)** option. Pre-select `linear.projectId` from
|
|
329
|
+
`linear.config.json` when it's set and still in the list; otherwise pre-select
|
|
330
|
+
**None**.
|
|
331
|
+
3. **Narrow on request.** If the user types a fragment rather than choosing, filter
|
|
332
|
+
the list case-insensitively by name and re-offer. Don't re-fetch.
|
|
333
|
+
4. **Never offer to create a project.** Projects are the PM's surface — if none
|
|
334
|
+
fits, that's **None (team only)**, and someone makes the project in Linear.
|
|
335
|
+
5. **Pass it once**, to whichever thing mints the issue: `--project <chosen id>`
|
|
336
|
+
on `spec-sync apply`, or `project: <chosen id>` on the MCP issue-create call.
|
|
337
|
+
Chose None → omit it entirely (do not pass an empty string).
|
|
338
|
+
|
|
339
|
+
**Degrade, never block.** If the list can't be fetched — Linear not connected, no
|
|
340
|
+
project-list tool, no API key, or `spec-sync projects` reporting it couldn't ask —
|
|
341
|
+
say so in one line — *"project picker unavailable"* — and carry on with
|
|
342
|
+
`linear.projectId` if it's set, else no project at all. A missing picker must never fail the skill that called it.
|
|
343
|
+
|
|
344
|
+
If `linear.projectId` is set but that Project is archived or missing, relay
|
|
345
|
+
Linear's error and stop rather than silently minting an unparented issue.
|
|
346
|
+
|
|
315
347
|
## 5. Drive to GREEN
|
|
316
348
|
|
|
317
349
|
- Implement the **minimal, root-cause** fix. Match surrounding code; honour all
|
|
@@ -323,6 +323,38 @@ and **never auto-push git** — Linear's own automation reacts to real branch/PR
|
|
|
323
323
|
events later. Report the Linear issue URL as part of the skill's finish-up
|
|
324
324
|
message.
|
|
325
325
|
|
|
326
|
+
### Picking the Linear Project
|
|
327
|
+
|
|
328
|
+
Run this **only when minting a spec issue** — creating it for the first time. On
|
|
329
|
+
an update the issue already has a project (or deliberately has none), and that
|
|
330
|
+
placement is **Linear's to own**: never send `project` on an update, and never
|
|
331
|
+
record the choice in the spec file or the snapshot. A PM re-homing a spec issue
|
|
332
|
+
must not show up as drift or be overwritten on the next push.
|
|
333
|
+
|
|
334
|
+
1. **List the candidates.** Ask the engine:
|
|
335
|
+
`skitterspec spec-sync projects --json`. On the API path it returns the team's
|
|
336
|
+
projects; on the MCP path it says so, and you call the discovered project-list
|
|
337
|
+
tool instead. Drop archived / completed projects — they can't take new work.
|
|
338
|
+
2. **Offer them.** Show the names (most recently updated first is fine), plus an
|
|
339
|
+
explicit **None (team only)** option. Pre-select `linear.projectId` from
|
|
340
|
+
`linear.config.json` when it's set and still in the list; otherwise pre-select
|
|
341
|
+
**None**.
|
|
342
|
+
3. **Narrow on request.** If the user types a fragment rather than choosing, filter
|
|
343
|
+
the list case-insensitively by name and re-offer. Don't re-fetch.
|
|
344
|
+
4. **Never offer to create a project.** Projects are the PM's surface — if none
|
|
345
|
+
fits, that's **None (team only)**, and someone makes the project in Linear.
|
|
346
|
+
5. **Pass it once**, to whichever thing mints the issue: `--project <chosen id>`
|
|
347
|
+
on `spec-sync apply`, or `project: <chosen id>` on the MCP issue-create call.
|
|
348
|
+
Chose None → omit it entirely (do not pass an empty string).
|
|
349
|
+
|
|
350
|
+
**Degrade, never block.** If the list can't be fetched — Linear not connected, no
|
|
351
|
+
project-list tool, no API key, or `spec-sync projects` reporting it couldn't ask —
|
|
352
|
+
say so in one line — *"project picker unavailable"* — and carry on with
|
|
353
|
+
`linear.projectId` if it's set, else no project at all. A missing picker must never fail the skill that called it.
|
|
354
|
+
|
|
355
|
+
If `linear.projectId` is set but that Project is archived or missing, relay
|
|
356
|
+
Linear's error and stop rather than silently minting an unparented issue.
|
|
357
|
+
|
|
326
358
|
## 6. Drive to GREEN
|
|
327
359
|
|
|
328
360
|
- Implement the **minimal, root-cause** fix on the branch. Match surrounding code;
|
|
@@ -156,6 +156,14 @@ Relay the **unreferenced count** even when it is zero. A chore commit
|
|
|
156
156
|
legitimately carries no ticket and a *missed* trailer looks identical, so
|
|
157
157
|
omitting the number reads as "everything is accounted for" when it may not be.
|
|
158
158
|
|
|
159
|
+
**Bookkeeping commits are excluded.** A spec's `chore(spec): complete <name>`
|
|
160
|
+
commit carries the same ref as the code it describes but lands *after* the tag
|
|
161
|
+
that shipped it, so counting it would put the ticket in two consecutive releases.
|
|
162
|
+
A commit whose changed paths are **all** under `release.ignorePaths` (default
|
|
163
|
+
`specs/`) contributes nothing; one that touches an ignored path *and* a source
|
|
164
|
+
file still counts. Relay the **ignored count** whenever it is non-zero — that is
|
|
165
|
+
the report saying which commits it set aside.
|
|
166
|
+
|
|
159
167
|
## 8. `retarget` — after a Linear team is renamed
|
|
160
168
|
|
|
161
169
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.8.0",
|
|
4
4
|
"description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/cli.js
CHANGED
|
@@ -1151,25 +1151,53 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
|
|
|
1151
1151
|
// receipt is advisory metadata. `status` is read-only; `take` performs the switch
|
|
1152
1152
|
// (release/abort land in a later phase).
|
|
1153
1153
|
async function specEnvLive(dir, config, positional) {
|
|
1154
|
-
const action =
|
|
1154
|
+
const { action, specArg } = liveGrammar(dir, config, positional)
|
|
1155
1155
|
switch (action) {
|
|
1156
1156
|
case 'status':
|
|
1157
|
-
specEnvLiveStatus(dir, config,
|
|
1157
|
+
specEnvLiveStatus(dir, config, specArg)
|
|
1158
1158
|
break
|
|
1159
1159
|
case 'take':
|
|
1160
|
-
await specEnvLiveTake(dir, config,
|
|
1160
|
+
await specEnvLiveTake(dir, config, specArg)
|
|
1161
1161
|
break
|
|
1162
1162
|
case 'release':
|
|
1163
|
-
await specEnvLiveRelease(dir, config,
|
|
1163
|
+
await specEnvLiveRelease(dir, config, specArg)
|
|
1164
1164
|
break
|
|
1165
1165
|
case 'abort':
|
|
1166
1166
|
await specEnvLiveAbort(dir, config)
|
|
1167
1167
|
break
|
|
1168
1168
|
default:
|
|
1169
|
-
process.stdout.write(
|
|
1169
|
+
process.stdout.write(
|
|
1170
|
+
'Usage: skitterspec spec-env live <spec>|<base branch>|<take|release|abort|status> [spec]\n',
|
|
1171
|
+
)
|
|
1170
1172
|
}
|
|
1171
1173
|
}
|
|
1172
1174
|
|
|
1175
|
+
const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
|
|
1176
|
+
|
|
1177
|
+
// The two front doors every doc names — `/spec-live <spec>` and `/spec-live main`
|
|
1178
|
+
// — translated to verbs. They live here rather than in the command because
|
|
1179
|
+
// `.claude/commands/spec-live.md` relays `$ARGUMENTS` untranslated (it is a
|
|
1180
|
+
// pre-executed script, with no model turn to rewrite them); the skill this
|
|
1181
|
+
// replaced did the translation itself, which is how these forms came to be
|
|
1182
|
+
// documented but unimplemented. `connect` needs no equivalent — its argument was
|
|
1183
|
+
// always spec-shaped (`specArg || 'main'`).
|
|
1184
|
+
//
|
|
1185
|
+
// VERB PRECEDENCE IS DELIBERATE, and so is the order below: the four verbs and
|
|
1186
|
+
// the base branch are matched BEFORE the spec-name fallback, so a spec folder
|
|
1187
|
+
// that happens to be called `status` cannot silently branch-switch the primary
|
|
1188
|
+
// checkout. Such a spec is still reachable — as `live take status`. The literal
|
|
1189
|
+
// `main` is honoured even where the base branch is named something else,
|
|
1190
|
+
// matching `connect main`, so the muscle memory works in either repo.
|
|
1191
|
+
function liveGrammar(dir, config, positional) {
|
|
1192
|
+
const [first, second] = positional
|
|
1193
|
+
if (!first) return { action: 'status', specArg: undefined }
|
|
1194
|
+
if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
|
|
1195
|
+
if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
|
|
1196
|
+
return { action: 'release', specArg: undefined }
|
|
1197
|
+
}
|
|
1198
|
+
return { action: 'take', specArg: first }
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1173
1201
|
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
1174
1202
|
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
1175
1203
|
async function specEnvLiveTake(dir, config, specArg) {
|
|
@@ -1466,7 +1494,9 @@ async function specEnv(rest) {
|
|
|
1466
1494
|
' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
|
|
1467
1495
|
' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
|
|
1468
1496
|
' NOTE connect and live status keep their own meaning for a missing spec:\n' +
|
|
1469
|
-
' connect disconnects (= main), live status reports on the whole repo.\n'
|
|
1497
|
+
' connect disconnects (= main), live status reports on the whole repo.\n' +
|
|
1498
|
+
' connect and live also take a bare spec name: `live <spec>` takes the\n' +
|
|
1499
|
+
' instance, `live main` (or your base branch) hands it back.\n',
|
|
1470
1500
|
)
|
|
1471
1501
|
}
|
|
1472
1502
|
}
|
|
@@ -61,6 +61,7 @@ const {
|
|
|
61
61
|
CONFIG_FILE,
|
|
62
62
|
LIFECYCLE_BUCKETS,
|
|
63
63
|
releaseStages,
|
|
64
|
+
releaseIgnorePaths,
|
|
64
65
|
stageFor,
|
|
65
66
|
} = require('./config.js')
|
|
66
67
|
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
@@ -942,6 +943,38 @@ function unreachableBase(git, range) {
|
|
|
942
943
|
return git(['merge-base', '--is-ancestor', base, head]) === null ? base : null
|
|
943
944
|
}
|
|
944
945
|
|
|
946
|
+
/**
|
|
947
|
+
* The paths each commit in the range changed, as sha → string[], or null when
|
|
948
|
+
* git could not be asked at all.
|
|
949
|
+
*
|
|
950
|
+
* A SECOND `git log` rather than `--name-only` on the first one: that format is
|
|
951
|
+
* NUL-delimited so a body cannot split a record, and appending a file list to
|
|
952
|
+
* the same stream would put one commit's files inside the next commit's record.
|
|
953
|
+
* `-z` also stops git C-quoting a non-ASCII filename (`"specs/\303\251.md"`),
|
|
954
|
+
* which a prefix match would then miss.
|
|
955
|
+
*
|
|
956
|
+
* Returning null on failure is deliberate: the caller leaves every commit's
|
|
957
|
+
* paths unknown, nothing is filtered, and the report is exactly what it was
|
|
958
|
+
* before the filter existed. A read that failed must not be able to empty a
|
|
959
|
+
* release.
|
|
960
|
+
*/
|
|
961
|
+
function readChangedPaths(git, range) {
|
|
962
|
+
const raw = git(['log', '-z', '--format=%x1e%H', '--name-only', range])
|
|
963
|
+
if (raw === null) return null
|
|
964
|
+
const bySha = new Map()
|
|
965
|
+
for (const chunk of raw.split('\x1e')) {
|
|
966
|
+
if (!chunk.trim()) continue
|
|
967
|
+
const [sha, ...names] = chunk.split('\x00')
|
|
968
|
+
const id = String(sha || '').trim()
|
|
969
|
+
if (!id) continue
|
|
970
|
+
bySha.set(
|
|
971
|
+
id,
|
|
972
|
+
names.map((n) => n.replace(/^\n+/, '').trim()).filter(Boolean),
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
return bySha
|
|
976
|
+
}
|
|
977
|
+
|
|
945
978
|
/**
|
|
946
979
|
* Resolve a commit range and read it, shared by `released` (which reports on it)
|
|
947
980
|
* and `stage` (which acts on it). Both must agree on what a release contains,
|
|
@@ -1007,6 +1040,14 @@ function readCommitRange(dir, rangeArg, verb) {
|
|
|
1007
1040
|
return { sha, subject, body: rest.join('\x00') }
|
|
1008
1041
|
})
|
|
1009
1042
|
|
|
1043
|
+
// What each commit CHANGED, so bookkeeping can be told from shipped work. A
|
|
1044
|
+
// commit git listed no files for keeps `paths: null` — unknown, never
|
|
1045
|
+
// "changed nothing" — see `onlyIgnoredPaths`.
|
|
1046
|
+
const pathsBySha = readChangedPaths(git, range)
|
|
1047
|
+
for (const commit of commits) {
|
|
1048
|
+
commit.paths = (pathsBySha && pathsBySha.get(commit.sha)) || null
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1010
1051
|
return { range, commits }
|
|
1011
1052
|
}
|
|
1012
1053
|
|
|
@@ -1018,7 +1059,7 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
1018
1059
|
}
|
|
1019
1060
|
const { range, commits } = read
|
|
1020
1061
|
|
|
1021
|
-
const report = ticketsInRange(commits)
|
|
1062
|
+
const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
|
|
1022
1063
|
|
|
1023
1064
|
// Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
|
|
1024
1065
|
// transport, or a read failure degrades to bare refs — never a failure.
|
|
@@ -1061,6 +1102,12 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
1061
1102
|
// MISSED trailer looks identical, so silence here would read as "everything is
|
|
1062
1103
|
// accounted for".
|
|
1063
1104
|
lines.push(` ${report.unreferenced} commit(s) carry no ref`)
|
|
1105
|
+
// A filter that removes commits without saying so reads as "there was nothing
|
|
1106
|
+
// there". Named only when it actually fired — on a project with no paperwork
|
|
1107
|
+
// in the range there is nothing to disclose.
|
|
1108
|
+
if (report.ignored) {
|
|
1109
|
+
lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
|
|
1110
|
+
}
|
|
1064
1111
|
out.write(lines.join('\n') + '\n')
|
|
1065
1112
|
return 0
|
|
1066
1113
|
}
|
|
@@ -1110,7 +1157,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1110
1157
|
}
|
|
1111
1158
|
const { range, commits } = read
|
|
1112
1159
|
|
|
1113
|
-
const report = ticketsInRange(commits)
|
|
1160
|
+
const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
|
|
1114
1161
|
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
1115
1162
|
const parts = partitionStageMoves({
|
|
1116
1163
|
tickets: report.tickets,
|
|
@@ -1211,6 +1258,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1211
1258
|
unreadable: unreadable.map((t) => t.ref),
|
|
1212
1259
|
},
|
|
1213
1260
|
unreferencedCommits: report.unreferenced,
|
|
1261
|
+
ignoredCommits: report.ignored,
|
|
1214
1262
|
totalCommits: report.total,
|
|
1215
1263
|
},
|
|
1216
1264
|
null,
|
|
@@ -1248,6 +1296,9 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1248
1296
|
// legitimately carries no ref and a MISSED trailer looks identical, so silence
|
|
1249
1297
|
// would read as "every commit is accounted for".
|
|
1250
1298
|
lines.push(` ${report.unreferenced} commit(s) carry no ref, of ${report.total}`)
|
|
1299
|
+
if (report.ignored) {
|
|
1300
|
+
lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
|
|
1301
|
+
}
|
|
1251
1302
|
if (!applying) lines.push(' dry run — pass --apply to move them')
|
|
1252
1303
|
out.write(lines.join('\n') + '\n')
|
|
1253
1304
|
return failed.length ? 1 : 0
|
|
@@ -92,6 +92,23 @@ const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
|
|
|
92
92
|
// LIFECYCLE_BUCKETS.
|
|
93
93
|
const DEFAULT_RELEASE_STAGES = Object.freeze([])
|
|
94
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
|
+
|
|
95
112
|
const DEFAULT_CONFIG = Object.freeze({
|
|
96
113
|
// `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
|
|
97
114
|
// first `/spec-push` offer the team's projects and pre-select this one; empty
|
|
@@ -128,7 +145,9 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
128
145
|
// See DEFAULT_RELEASE_STAGES above. `stages` is ordered: the order is recorded
|
|
129
146
|
// for reporting and doctor's ladder check, and deliberately NOT enforced — a
|
|
130
147
|
// rollback from test and a hotfix going straight to prod are both legitimate.
|
|
131
|
-
|
|
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 }),
|
|
132
151
|
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
133
152
|
// `keyEnv` names the env var holding the personal API key. It is a NAME, not a
|
|
134
153
|
// key: putting the secret itself here would commit it.
|
|
@@ -180,7 +199,10 @@ function defaults() {
|
|
|
180
199
|
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
181
200
|
states: { ...DEFAULT_CONFIG.states },
|
|
182
201
|
snapshot: { ...DEFAULT_CONFIG.snapshot },
|
|
183
|
-
release: {
|
|
202
|
+
release: {
|
|
203
|
+
stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })),
|
|
204
|
+
ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
|
|
205
|
+
},
|
|
184
206
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
185
207
|
auth: { ...DEFAULT_CONFIG.auth },
|
|
186
208
|
apply: { ...DEFAULT_CONFIG.apply },
|
|
@@ -317,6 +339,35 @@ function mergeReleaseStages(base, parsed) {
|
|
|
317
339
|
base.stages = stages
|
|
318
340
|
}
|
|
319
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
|
+
|
|
320
371
|
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
321
372
|
// name (a non-empty string); a field listed here is compared per item.
|
|
322
373
|
function mergeKeyedFields(base, parsed) {
|
|
@@ -382,6 +433,7 @@ function mergeConfig(base, parsed) {
|
|
|
382
433
|
|
|
383
434
|
if (isObject(parsed.release)) {
|
|
384
435
|
mergeReleaseStages(base.release, parsed.release)
|
|
436
|
+
mergeReleaseIgnorePaths(base.release, parsed.release)
|
|
385
437
|
}
|
|
386
438
|
|
|
387
439
|
if (isObject(parsed.branch)) {
|
|
@@ -462,9 +514,23 @@ function stageFor(config, key) {
|
|
|
462
514
|
return releaseStages(config).find((s) => s.key === key) || null
|
|
463
515
|
}
|
|
464
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
|
+
|
|
465
530
|
module.exports = {
|
|
466
531
|
loadLinearConfig,
|
|
467
532
|
releaseStages,
|
|
533
|
+
releaseIgnorePaths,
|
|
468
534
|
stageFor,
|
|
469
535
|
mergeConfig,
|
|
470
536
|
defaults,
|
|
@@ -476,4 +542,5 @@ module.exports = {
|
|
|
476
542
|
LIFECYCLE_BUCKETS,
|
|
477
543
|
TRANSPORTS,
|
|
478
544
|
DEFAULT_KEY_ENV,
|
|
545
|
+
DEFAULT_RELEASE_IGNORE_PATHS,
|
|
479
546
|
}
|
|
@@ -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,6 +132,7 @@ 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
|
}
|
|
@@ -161,4 +221,4 @@ function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
|
|
|
161
221
|
return null
|
|
162
222
|
}
|
|
163
223
|
|
|
164
|
-
module.exports = { ticketsInRange, refsInBody, partitionStageMoves, stageOrderWarning }
|
|
224
|
+
module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
|