@skitterbyte/skitterspec-linear 10.5.2 → 10.6.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/rules/commit-trailers.md +52 -0
- package/assets/skills/spec-bug/SKILL.md +24 -0
- package/assets/skills/spec-go/SKILL.md +50 -20
- package/assets/skills/spec-hotfix/SKILL.md +24 -0
- package/assets/skills/spec-sync/SKILL.md +26 -2
- package/package.json +1 -1
- package/src/vendor/linear/cli-sync.js +180 -1
- package/src/vendor/linear/released.js +80 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Commit Trailers
|
|
2
|
+
|
|
3
|
+
Extends `commit-messages.md` — the format, length limits and `Release-Note:`
|
|
4
|
+
grammar there all still apply. This file adds one trailer, and it is only
|
|
5
|
+
installed when a ticketing provider is.
|
|
6
|
+
|
|
7
|
+
## `Refs:` — the ticket this commit belongs to
|
|
8
|
+
|
|
9
|
+
A commit made on a spec's branch carries the Linear issue it belongs to, as the
|
|
10
|
+
**last** trailer in the message:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
fix(sync): verify no longer flags every update
|
|
14
|
+
|
|
15
|
+
- Key the apply read-back by ref, resolving id via the projection
|
|
16
|
+
|
|
17
|
+
Release-Note: Pushing a spec whose phases already exist in Linear no
|
|
18
|
+
longer reports every one of them as a possible stale reference.
|
|
19
|
+
|
|
20
|
+
Refs: SKS-29
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Get the value from the engine rather than reading it off a spec by hand:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pnpm exec skitterspec-linear spec-sync ref
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- **Omit the trailer entirely when there is no ref.** `spec-sync ref` exits
|
|
32
|
+
non-zero and prints nothing on a commit that has no ticket — on `main`, or on
|
|
33
|
+
a spec kept deliberately local. Never invent one, and never write
|
|
34
|
+
`Refs: none`: the release report counts unreferenced commits, and a fabricated
|
|
35
|
+
ref is worse than an honest gap.
|
|
36
|
+
- **One ref per commit.** A commit belongs to one spec — the spec whose branch
|
|
37
|
+
it is on. If work genuinely spans two tickets, that is two commits.
|
|
38
|
+
- **Never use Linear's magic words** — `Fixes`, `Closes`, `Resolves`. Those
|
|
39
|
+
close the issue the moment the commit reaches the default branch, which is
|
|
40
|
+
wrong here: a ticket moves when its work is **released**, not when it merges.
|
|
41
|
+
`Refs:` is deliberately inert to Linear's automation.
|
|
42
|
+
- **Blank line before it**, like `Release-Note:` — it is a git trailer, not body
|
|
43
|
+
text.
|
|
44
|
+
- Put it **after** `Release-Note:` when both are present, so the human-facing
|
|
45
|
+
note reads first.
|
|
46
|
+
|
|
47
|
+
## Why it exists
|
|
48
|
+
|
|
49
|
+
The repo lands specs with `merge --ff-only`, so history is linear and branch
|
|
50
|
+
names never reach it. The commit message is the only artefact that survives into
|
|
51
|
+
the range a release scans, which is what lets
|
|
52
|
+
`spec-sync released <range>` report the tickets a release contains.
|
|
@@ -311,6 +311,30 @@ test, split the fix into phase files (`01-<slug>.md` …) with a phase index in
|
|
|
311
311
|
`00-overview.md`, and leave the spec in `in-progress` for `/spec-go` to continue.
|
|
312
312
|
Say so explicitly — don't fake green.
|
|
313
313
|
|
|
314
|
+
**Then refresh the mirror (only if a provider is installed).** The Fix tasks are
|
|
315
|
+
ticked, so the repo is now the truth about this fix — and this skill can take a
|
|
316
|
+
bug all the way to green without `/spec-go` ever running. Without a provider this
|
|
317
|
+
is a no-op.
|
|
318
|
+
|
|
319
|
+
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
320
|
+
`linear_identifier`. Either missing → **skip**, in one line
|
|
321
|
+
(`not linked to Linear — /spec-push to mirror it`), and carry on.
|
|
322
|
+
|
|
323
|
+
**Refresh the mirror now, without asking.** Run `/spec-push`. The repo has just
|
|
324
|
+
become the truth about this phase's progress, and progress is what the mirror
|
|
325
|
+
exists to show. Deferring it to `/spec-complete` is what makes every phase
|
|
326
|
+
sub-issue jump from Backlog straight to Done, with nothing visible in between.
|
|
327
|
+
|
|
328
|
+
- **Never mint.** An unlinked spec is skipped, not created.
|
|
329
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
330
|
+
and **finish the operation anyway**. The phase is done in the repo regardless;
|
|
331
|
+
the mirror is disposable and the next push repairs it.
|
|
332
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
333
|
+
and stamps any new ids, and these skills do not commit. The next `/commit`
|
|
334
|
+
sweeps it up with the phase's own work.
|
|
335
|
+
- **Say what happened** in the skill's report: mirror updated, skipped as
|
|
336
|
+
unlinked, or failed with the reason.
|
|
337
|
+
|
|
314
338
|
## 6. Report
|
|
315
339
|
|
|
316
340
|
Summarise: root cause, the failing→passing test, the fix, and the full test
|
|
@@ -139,11 +139,17 @@ Before writing any code for this phase, get the workspace clean:
|
|
|
139
139
|
next phase on top of an uncommitted one. (Skip if this is the first phase —
|
|
140
140
|
there's nothing prior to commit.)
|
|
141
141
|
|
|
142
|
-
##
|
|
142
|
+
## 4. Implement the phase
|
|
143
|
+
|
|
144
|
+
Identify the **first unfinished phase** from the `00-overview.md` phase index,
|
|
145
|
+
then open its phase file (`0N-<slug>.md`) — that file holds the tasks. Mark it
|
|
146
|
+
started: set the phase-file heading to `🔄` and its `> **Status:**` to
|
|
147
|
+
`In progress`, and flip the matching row in the overview phase index to `🔄`.
|
|
143
148
|
|
|
144
|
-
**
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
**Then sync with the tracker (only if a provider is installed).** The phase has
|
|
150
|
+
just changed state, so refresh the mirror before the build starts — that is what
|
|
151
|
+
makes the phase show as in progress *while* it is being built rather than only
|
|
152
|
+
once it is over. Without a provider this is a no-op and nothing below changes.
|
|
147
153
|
|
|
148
154
|
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
149
155
|
`linear_identifier`. Otherwise skip this step — no config means zero change.
|
|
@@ -152,26 +158,27 @@ below. Follow the provider's steps below (nothing to do here without one).
|
|
|
152
158
|
to bring down before building — the repo is already the source of truth. (A
|
|
153
159
|
workflow-state a teammate moved in Linear is advisory only; `/spec-status`
|
|
154
160
|
surfaces it. It is overwritten on the next push.)
|
|
155
|
-
- **Refresh the mirror.** Run `/spec-push
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
- `"
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
- **Refresh the mirror now, without asking.** Run `/spec-push`. The spec has just
|
|
162
|
+
moved to `in-progress` and its phase to `🔄` — both real state changes, and the
|
|
163
|
+
tracker is a generated mirror of them. This holds under **both**
|
|
164
|
+
`mapping.phases` modes, for different reasons:
|
|
165
|
+
- `"subissue"` (the default) — the phase sub-issues already exist, and this
|
|
166
|
+
push is what moves the current one into its in-progress state. Skip it and
|
|
167
|
+
every sub-issue sits in Backlog until the spec completes.
|
|
168
|
+
- `"deferred"` — the sub-issues do not exist yet, and this push is what mints
|
|
169
|
+
them. Skip it and a started spec stays mirrored as a phase-less issue.
|
|
170
|
+
- **Never mint the spec issue.** An unlinked spec is skipped, not created —
|
|
171
|
+
`/spec-push` is how someone opts in.
|
|
172
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
173
|
+
and **carry on with the build**. The repo is correct regardless; the mirror is
|
|
174
|
+
disposable and the next push repairs it.
|
|
175
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
176
|
+
and stamps any new ids, and `/spec-go` does not commit. The next `/commit`
|
|
177
|
+
sweeps it up with the phase's own work.
|
|
165
178
|
- Linear's GitHub branch/PR automation may drive status transitions off the
|
|
166
179
|
branch/PR you pushed in step 2; that's expected and the repo still wins on the
|
|
167
180
|
next `/spec-push`.
|
|
168
181
|
|
|
169
|
-
## 4. Implement the phase
|
|
170
|
-
|
|
171
|
-
Identify the **first unfinished phase** from the `00-overview.md` phase index,
|
|
172
|
-
then open its phase file (`0N-<slug>.md`) — that file holds the tasks. Mark it
|
|
173
|
-
started: set the phase-file heading to `🔄` and its `> **Status:**` to
|
|
174
|
-
`In progress`, and flip the matching row in the overview phase index to `🔄`.
|
|
175
182
|
Then build it, following the project rules in `.claude/rules/*.md` and `CLAUDE.md`:
|
|
176
183
|
|
|
177
184
|
- Work task by task through the phase file. Make focused edits that match
|
|
@@ -194,6 +201,29 @@ Then build it, following the project rules in `.claude/rules/*.md` and `CLAUDE.m
|
|
|
194
201
|
- If new work surfaced, add it as tasks to the appropriate phase file (or add a
|
|
195
202
|
new phase file + index row) rather than doing it silently.
|
|
196
203
|
|
|
204
|
+
**Then refresh the mirror (only if a provider is installed).** The phase is done
|
|
205
|
+
in the repo now; leaving the tracker to catch up at `/spec-complete` is what makes
|
|
206
|
+
a mirror lag a whole spec behind. Without a provider this is a no-op.
|
|
207
|
+
|
|
208
|
+
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
209
|
+
`linear_identifier`. Either missing → **skip**, in one line
|
|
210
|
+
(`not linked to Linear — /spec-push to mirror it`), and carry on.
|
|
211
|
+
|
|
212
|
+
**Refresh the mirror now, without asking.** Run `/spec-push`. The repo has just
|
|
213
|
+
become the truth about this phase's progress, and progress is what the mirror
|
|
214
|
+
exists to show. Deferring it to `/spec-complete` is what makes every phase
|
|
215
|
+
sub-issue jump from Backlog straight to Done, with nothing visible in between.
|
|
216
|
+
|
|
217
|
+
- **Never mint.** An unlinked spec is skipped, not created.
|
|
218
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
219
|
+
and **finish the operation anyway**. The phase is done in the repo regardless;
|
|
220
|
+
the mirror is disposable and the next push repairs it.
|
|
221
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
222
|
+
and stamps any new ids, and these skills do not commit. The next `/commit`
|
|
223
|
+
sweeps it up with the phase's own work.
|
|
224
|
+
- **Say what happened** in the skill's report: mirror updated, skipped as
|
|
225
|
+
unlinked, or failed with the reason.
|
|
226
|
+
|
|
197
227
|
## 6. Report
|
|
198
228
|
|
|
199
229
|
Summarise what was implemented, the test result (quote failures if any), and
|
|
@@ -318,6 +318,30 @@ message.
|
|
|
318
318
|
- Commit the fix to the `hotfix/<slug>` branch (this commit is what gets tagged
|
|
319
319
|
and cherry-picked). Tick the Fix tasks; add a Changelog line.
|
|
320
320
|
|
|
321
|
+
**Then refresh the mirror (only if a provider is installed).** The Fix tasks are
|
|
322
|
+
ticked, so the repo is now the truth about this fix — and this skill can take a
|
|
323
|
+
bug all the way to green without `/spec-go` ever running. Without a provider this
|
|
324
|
+
is a no-op.
|
|
325
|
+
|
|
326
|
+
**Only when `specs/.core/linear.config.json` exists** and the spec carries a
|
|
327
|
+
`linear_identifier`. Either missing → **skip**, in one line
|
|
328
|
+
(`not linked to Linear — /spec-push to mirror it`), and carry on.
|
|
329
|
+
|
|
330
|
+
**Refresh the mirror now, without asking.** Run `/spec-push`. The repo has just
|
|
331
|
+
become the truth about this phase's progress, and progress is what the mirror
|
|
332
|
+
exists to show. Deferring it to `/spec-complete` is what makes every phase
|
|
333
|
+
sub-issue jump from Backlog straight to Done, with nothing visible in between.
|
|
334
|
+
|
|
335
|
+
- **Never mint.** An unlinked spec is skipped, not created.
|
|
336
|
+
- **Never fatal.** If the push fails — offline, no key, a Linear error — say so
|
|
337
|
+
and **finish the operation anyway**. The phase is done in the repo regardless;
|
|
338
|
+
the mirror is disposable and the next push repairs it.
|
|
339
|
+
- **Expect a dirty `specs/.core/` afterwards.** The push writes a base snapshot
|
|
340
|
+
and stamps any new ids, and these skills do not commit. The next `/commit`
|
|
341
|
+
sweeps it up with the phase's own work.
|
|
342
|
+
- **Say what happened** in the skill's report: mirror updated, skipped as
|
|
343
|
+
unlinked, or failed with the reason.
|
|
344
|
+
|
|
321
345
|
## 7. Report
|
|
322
346
|
|
|
323
347
|
Summarise: the base tag, root cause, the failing→passing test, the fix, and the
|
|
@@ -37,6 +37,8 @@ In a project that installs the Linear superset the binary is
|
|
|
37
37
|
| "link this spec to KEY-1 by hand" | `stamp <spec> --issue KEY-1` |
|
|
38
38
|
| "mirror the whole backlog / every complete spec" | `apply --all <bucket>` — **confirm first** |
|
|
39
39
|
| "is the team key stale?", "did Linear get renamed?" | `retarget` |
|
|
40
|
+
| "what ticket am I on?", writing a commit | `ref` |
|
|
41
|
+
| "what shipped in this release?" | `released` |
|
|
40
42
|
| push one spec, or "what would push?" | **defer** — see below |
|
|
41
43
|
|
|
42
44
|
**With no argument, run `linked`.** It is the repo-wide overview, it is
|
|
@@ -131,7 +133,29 @@ its object exists, so an interrupted run continues rather than duplicating.
|
|
|
131
133
|
|
|
132
134
|
`--all` refuses over MCP by design — bulk goes through the API path.
|
|
133
135
|
|
|
134
|
-
## 7. `
|
|
136
|
+
## 7. `ref` and `released` — tickets in commits
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
pnpm exec skitterspec-linear spec-sync ref [--json]
|
|
140
|
+
pnpm exec skitterspec-linear spec-sync released [<range>] [--json]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`ref` prints the ticket for the branch you are on, so a commit can carry
|
|
144
|
+
`Refs: <KEY-N>` — see `.claude/rules/commit-trailers.md`. **Off a spec branch, or
|
|
145
|
+
on a spec that is not linked, it prints nothing and exits non-zero.** That is the
|
|
146
|
+
correct answer, not an error to work around: do not invent a ref, and never write
|
|
147
|
+
`Refs: none`.
|
|
148
|
+
|
|
149
|
+
`released` reports the tickets in a commit range — by default since the most
|
|
150
|
+
recent tag, and it always prints the range it chose so a wrong default is
|
|
151
|
+
visible. It is **read-only**: it does not move anything in Linear. A release can
|
|
152
|
+
be cut and never deployed, so transitioning tickets stays a deliberate act.
|
|
153
|
+
|
|
154
|
+
Relay the **unreferenced count** even when it is zero. A chore commit
|
|
155
|
+
legitimately carries no ticket and a *missed* trailer looks identical, so
|
|
156
|
+
omitting the number reads as "everything is accounted for" when it may not be.
|
|
157
|
+
|
|
158
|
+
## 8. `retarget` — after a Linear team is renamed
|
|
135
159
|
|
|
136
160
|
```
|
|
137
161
|
pnpm exec skitterspec-linear spec-sync retarget [--yes]
|
|
@@ -160,7 +184,7 @@ rewrites those fields. Read-only until `--yes`.
|
|
|
160
184
|
Over MCP the team key is unreadable (`get_team` does not return it), so it says
|
|
161
185
|
so and asks you to confirm the key rather than guessing.
|
|
162
186
|
|
|
163
|
-
##
|
|
187
|
+
## 9. Report
|
|
164
188
|
|
|
165
189
|
Relay the engine's output. Name the subcommand you ran, in full, so the user can
|
|
166
190
|
re-run it themselves. For anything that wrote, say what changed in Linear and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.6.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",
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
const fs = require('node:fs')
|
|
24
24
|
const path = require('node:path')
|
|
25
25
|
|
|
26
|
-
const { BUCKETS, findSpecFolder } = require('../../env/resolve.js')
|
|
26
|
+
const { BUCKETS, findSpecFolder, branchFor, splitPrefix, currentBranch } = require('../../env/resolve.js')
|
|
27
|
+
const { loadEnvConfig } = require('../../env/config.js')
|
|
28
|
+
const { ticketsInRange } = require('./released.js')
|
|
29
|
+
const { execFileSync } = require('node:child_process')
|
|
27
30
|
const {
|
|
28
31
|
normalizeLocal,
|
|
29
32
|
readSnapshot,
|
|
@@ -860,6 +863,176 @@ function countSkills(dir) {
|
|
|
860
863
|
}
|
|
861
864
|
}
|
|
862
865
|
|
|
866
|
+
/**
|
|
867
|
+
* `spec-sync released [<range>] [--json]` — the tickets a release contains.
|
|
868
|
+
*
|
|
869
|
+
* Read-only, and deliberately so: it reports what shipped, it does not move
|
|
870
|
+
* anything. A release can be cut and never deployed, and workflow state is
|
|
871
|
+
* pushed from a spec's lifecycle bucket — "released" is not one, so moving
|
|
872
|
+
* tickets here would be a new kind of write with no dry run.
|
|
873
|
+
*
|
|
874
|
+
* The default range is the most recent tag reachable from HEAD, resolved with
|
|
875
|
+
* `git describe` rather than by knowing a tag scheme. This repo tags
|
|
876
|
+
* `name@version`, but a consumer may tag `v1.2.3` — and the scheme lives in
|
|
877
|
+
* `scripts/`, which is not shipped. The chosen range is always printed, so a
|
|
878
|
+
* wrong guess is visible rather than silent.
|
|
879
|
+
*/
|
|
880
|
+
async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
881
|
+
const git = (argv) => {
|
|
882
|
+
try {
|
|
883
|
+
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString()
|
|
884
|
+
} catch {
|
|
885
|
+
return null
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
let range = rangeArg
|
|
890
|
+
if (!range) {
|
|
891
|
+
const tag = (git(['describe', '--tags', '--abbrev=0']) || '').trim()
|
|
892
|
+
if (!tag) {
|
|
893
|
+
out.write(
|
|
894
|
+
'spec-sync released: no range given and no tag to default from.\n' +
|
|
895
|
+
' Pass one explicitly, e.g. spec-sync released v1.2.0..HEAD\n',
|
|
896
|
+
)
|
|
897
|
+
return 1
|
|
898
|
+
}
|
|
899
|
+
range = `${tag}..HEAD`
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// NUL-delimited so a subject or body containing the separator cannot split a
|
|
903
|
+
// record; RS between commits for the same reason.
|
|
904
|
+
const raw = git(['log', '--format=%H%x00%s%x00%b%x1e', range])
|
|
905
|
+
if (raw === null) {
|
|
906
|
+
out.write(`spec-sync released: git could not resolve the range "${range}".\n`)
|
|
907
|
+
return 1
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const commits = raw
|
|
911
|
+
.split('\x1e')
|
|
912
|
+
.map((r) => r.replace(/^\n/, ''))
|
|
913
|
+
.filter((r) => r.trim())
|
|
914
|
+
.map((record) => {
|
|
915
|
+
const [sha, subject, ...rest] = record.split('\x00')
|
|
916
|
+
return { sha, subject, body: rest.join('\x00') }
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
const report = ticketsInRange(commits)
|
|
920
|
+
|
|
921
|
+
// Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
|
|
922
|
+
// transport, or a read failure degrades to bare refs — never a failure.
|
|
923
|
+
const key = resolveApiKey(config, flags.env || process.env)
|
|
924
|
+
const transport = flags.via || (config.apply && config.apply.transport) || (key.ok ? 'api' : 'mcp')
|
|
925
|
+
let titles = null
|
|
926
|
+
let titleNote = ''
|
|
927
|
+
if (report.tickets.length && transport === 'api') {
|
|
928
|
+
const adapter = flags.adapter || makeApiAdapter({ apiKey: key.key, fetch: flags.fetch })
|
|
929
|
+
titles = {}
|
|
930
|
+
for (const t of report.tickets) {
|
|
931
|
+
try {
|
|
932
|
+
const issue = await adapter.readIssue(t.ref)
|
|
933
|
+
if (issue && issue.title) titles[t.ref] = issue.title
|
|
934
|
+
} catch {
|
|
935
|
+
titleNote = ' (titles unavailable — Linear could not be read)'
|
|
936
|
+
titles = null
|
|
937
|
+
break
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
} else if (report.tickets.length) {
|
|
941
|
+
titleNote = ` (titles unavailable — transport = ${transport})`
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (flags.json) {
|
|
945
|
+
out.write(JSON.stringify({ range, ...report, titles }, null, 2) + '\n')
|
|
946
|
+
return 0
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const lines = [`spec-sync released: ${range}`, '']
|
|
950
|
+
if (!report.tickets.length) lines.push(' no ticket references found')
|
|
951
|
+
for (const t of report.tickets) {
|
|
952
|
+
const title = titles && titles[t.ref] ? ` ${titles[t.ref]}` : ''
|
|
953
|
+
lines.push(` ${t.ref}${title}`)
|
|
954
|
+
}
|
|
955
|
+
if (titleNote) lines.push(titleNote)
|
|
956
|
+
lines.push('')
|
|
957
|
+
lines.push(` ${report.tickets.length} ticket(s) in ${report.total} commit(s)`)
|
|
958
|
+
// Said even when zero: a chore commit legitimately carries no ticket and a
|
|
959
|
+
// MISSED trailer looks identical, so silence here would read as "everything is
|
|
960
|
+
// accounted for".
|
|
961
|
+
lines.push(` ${report.unreferenced} commit(s) carry no ref`)
|
|
962
|
+
out.write(lines.join('\n') + '\n')
|
|
963
|
+
return 0
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* `spec-sync ref [--json]` — the ticket this branch's work belongs to.
|
|
968
|
+
*
|
|
969
|
+
* Exists so neither a person nor a model has to go spelunking for the id when
|
|
970
|
+
* writing a commit: `Refs: $(spec-sync ref)`. With a fast-forward-only history
|
|
971
|
+
* the commit message is the ONLY place a ticket survives into the range a
|
|
972
|
+
* release scans — branch names never reach it.
|
|
973
|
+
*
|
|
974
|
+
* The branch→spec direction is the INVERSE of what `/spec-go` provisions with,
|
|
975
|
+
* so it is computed by running `branchFor` over each spec and matching, rather
|
|
976
|
+
* than by re-deriving the pattern here. A second implementation of the naming
|
|
977
|
+
* rule would drift from the one that created the branch.
|
|
978
|
+
*
|
|
979
|
+
* **Every no-ref case prints nothing on stdout** and exits non-zero. A commit on
|
|
980
|
+
* `main`, or on a spec kept deliberately local, has no ticket — and a command
|
|
981
|
+
* that wrote an error message to stdout would see a shell splice it straight
|
|
982
|
+
* into the commit body via `$(…)`.
|
|
983
|
+
*/
|
|
984
|
+
function specSyncRef(dir, config, flags, out, err) {
|
|
985
|
+
const say = (msg) => err.write(`spec-sync ref: ${msg}\n`)
|
|
986
|
+
|
|
987
|
+
const git = (argv) => {
|
|
988
|
+
try {
|
|
989
|
+
return execFileSync('git', ['-C', dir, ...argv], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
|
990
|
+
} catch {
|
|
991
|
+
return null
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const branch = currentBranch(git)
|
|
995
|
+
if (!branch) {
|
|
996
|
+
say('not on a git branch (detached HEAD, or not a git repository)')
|
|
997
|
+
return 1
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const env = loadEnvConfig(dir).config
|
|
1001
|
+
let match = null
|
|
1002
|
+
for (const bucket of BUCKETS) {
|
|
1003
|
+
let entries
|
|
1004
|
+
try {
|
|
1005
|
+
entries = fs.readdirSync(path.join(dir, 'specs', bucket), { withFileTypes: true })
|
|
1006
|
+
} catch {
|
|
1007
|
+
continue
|
|
1008
|
+
}
|
|
1009
|
+
for (const entry of entries) {
|
|
1010
|
+
if (!entry.isDirectory()) continue
|
|
1011
|
+
const specPath = path.join(dir, 'specs', bucket, entry.name)
|
|
1012
|
+
const { type, slug } = splitPrefix(entry.name)
|
|
1013
|
+
if (branchFor({ type, slug, path: specPath }, env) === branch) {
|
|
1014
|
+
match = { folder: entry.name, bucket, path: specPath }
|
|
1015
|
+
break
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (match) break
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (!match) {
|
|
1022
|
+
say(`branch "${branch}" is not a spec branch — no ticket to reference`)
|
|
1023
|
+
return 1
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const identifier = linkedIdentifier(path.join(match.path, (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'))
|
|
1027
|
+
if (!identifier) {
|
|
1028
|
+
say(`${match.folder} is not linked to Linear — /spec-push to mirror it`)
|
|
1029
|
+
return 1
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
out.write(flags.json ? JSON.stringify({ ref: identifier, spec: match.folder, branch }, null, 2) + '\n' : `${identifier}\n`)
|
|
1033
|
+
return 0
|
|
1034
|
+
}
|
|
1035
|
+
|
|
863
1036
|
/**
|
|
864
1037
|
* `spec-sync retarget [--yes]` — repoint a mirror after a team-key rename.
|
|
865
1038
|
*
|
|
@@ -2025,6 +2198,10 @@ async function specSync(rest, io = {}) {
|
|
|
2025
2198
|
return (await specSyncProjects(dir, config, flags, out)) || 0
|
|
2026
2199
|
case 'states':
|
|
2027
2200
|
return (await specSyncStates(dir, config, flags, out)) || 0
|
|
2201
|
+
case 'released':
|
|
2202
|
+
return (await specSyncReleased(dir, config, positional[0], flags, out)) || 0
|
|
2203
|
+
case 'ref':
|
|
2204
|
+
return specSyncRef(dir, config, flags, out, err) || 0
|
|
2028
2205
|
case 'retarget':
|
|
2029
2206
|
return (await specSyncRetarget(dir, config, flags, out)) || 0
|
|
2030
2207
|
case 'apply':
|
|
@@ -2047,6 +2224,8 @@ async function specSync(rest, io = {}) {
|
|
|
2047
2224
|
' skitterspec spec-sync apply --all <bucket> [--via api|mcp] [--json]\n' +
|
|
2048
2225
|
' skitterspec spec-sync verify <spec> --stored <file>\n' +
|
|
2049
2226
|
' skitterspec spec-sync linked [--json]\n' +
|
|
2227
|
+
' skitterspec spec-sync ref [--json]\n' +
|
|
2228
|
+
' skitterspec spec-sync released [<range>] [--json]\n' +
|
|
2050
2229
|
' skitterspec spec-sync retarget [--yes]\n' +
|
|
2051
2230
|
' skitterspec spec-sync doctor [--check-remote] [--mcp <file>] [--json]\n' +
|
|
2052
2231
|
' skitterspec spec-sync init-config --team-id <id> [--team-key K] [--project-id id]\n' +
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which tickets a release contains.
|
|
5
|
+
*
|
|
6
|
+
* The repo lands specs with `merge --ff-only`, so history is linear and branch
|
|
7
|
+
* names never reach it. A commit's `Refs:` trailer is therefore the only place
|
|
8
|
+
* the ticket survives into the range a release scans — see the
|
|
9
|
+
* `commit-trailers` rule.
|
|
10
|
+
*
|
|
11
|
+
* The parsing half is pure and takes commits as data, so the rules below are
|
|
12
|
+
* testable without git. Reading the range, and enriching refs with issue titles,
|
|
13
|
+
* belong to the caller.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// An issue identifier: uppercase team key, dash, number.
|
|
17
|
+
const IDENTIFIER_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g
|
|
18
|
+
// A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
|
|
19
|
+
// that happens to mention the word.
|
|
20
|
+
const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The refs a single commit body claims.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately blind to three things that LOOK like trailers and are not:
|
|
26
|
+
*
|
|
27
|
+
* - a line inside a ``` fence — a commit explaining the convention quotes it;
|
|
28
|
+
* - a quoted line (`> Refs: …`) — same reason, in review replies;
|
|
29
|
+
* - an indented line (4+ spaces) — markdown code, e.g. a sample message.
|
|
30
|
+
*
|
|
31
|
+
* Counting any of those would make a release claim work it does not contain,
|
|
32
|
+
* which is worse than missing a ticket: the missing one is noticed when someone
|
|
33
|
+
* looks for it, the invented one never is.
|
|
34
|
+
*/
|
|
35
|
+
function refsInBody(body) {
|
|
36
|
+
const found = []
|
|
37
|
+
let fenced = false
|
|
38
|
+
for (const raw of String(body || '').split('\n')) {
|
|
39
|
+
const line = raw.replace(/\r$/, '')
|
|
40
|
+
if (/^\s*```/.test(line)) {
|
|
41
|
+
fenced = !fenced
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (fenced) continue
|
|
45
|
+
if (/^\s*>/.test(line)) continue
|
|
46
|
+
if (/^ {4,}|\t/.test(line)) continue
|
|
47
|
+
const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
|
|
48
|
+
if (!m) continue
|
|
49
|
+
for (const id of m[1].match(IDENTIFIER_RE) || []) found.push(id)
|
|
50
|
+
}
|
|
51
|
+
return found
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fold commits into the report a release needs.
|
|
56
|
+
*
|
|
57
|
+
* @param {Array<{sha?:string, subject?:string, body?:string}>} commits
|
|
58
|
+
* @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, total:number}}
|
|
59
|
+
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
60
|
+
* is listed once, where it first appears, not eight times.
|
|
61
|
+
*/
|
|
62
|
+
function ticketsInRange(commits) {
|
|
63
|
+
const seen = new Map()
|
|
64
|
+
let unreferenced = 0
|
|
65
|
+
for (const commit of commits || []) {
|
|
66
|
+
const refs = [...new Set(refsInBody(commit && commit.body))]
|
|
67
|
+
if (!refs.length) {
|
|
68
|
+
unreferenced++
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
75
|
+
unreferenced,
|
|
76
|
+
total: (commits || []).length,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { ticketsInRange, refsInBody }
|