@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.
@@ -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
- ## 3b. Sync with the tracker (only if a provider is installed)
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
- **Only when a ticketing provider is installed** and the spec is linked to the
145
- tracker. Otherwise skip this step no provider means zero change to the flow
146
- below. Follow the provider's steps below (nothing to do here without one).
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` to send the now in-progress spec up.
156
- Whether that is optional depends on `mapping.phases`:
157
- - `"subissue"` (the default) **optional**. The phase sub-issues already
158
- exist; this push only moves their states. Refresh now or later.
159
- - `"deferred"` **do it now, without asking.** Under deferral a spec sitting
160
- in the backlog is mirrored as the issue alone, and this push is what mints
161
- its phase sub-issues. Skip it and a started spec stays mirrored as a
162
- phase-less issue until someone happens to run `/spec-push` by hand. Run it
163
- straight after the step-2 commit, so the sub-issues land with the same
164
- branch push that fires the tracker's automation.
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. `retarget` after a Linear team is renamed
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
- ## 8. Report
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.5.2",
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 }