@skitterbyte/skitterspec-linear 10.1.0 → 10.3.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,54 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The commands this provider distribution adds on top of the base CLI.
5
+ *
6
+ * ONE table drives both routing and `--help`. That pairing is the point: the bug
7
+ * this fixes was `spec-sync` being routed by the bin while the base's `HELP`
8
+ * const knew nothing about it, so the one distribution that ships the command
9
+ * told users it did not exist. Anything added here is routed and documented in
10
+ * the same edit — the two cannot drift, and a test asserts it.
11
+ *
12
+ * `run(rest)` returns an exit code (the bin propagates it); `summary` is the
13
+ * one-line description `--help` prints.
14
+ */
15
+
16
+ const { specSync } = require('./cli-sync.js')
17
+ const { specSanitise } = require('./cli-sanitise.js')
18
+
19
+ const DIST = '@skitterbyte/skitterspec-linear'
20
+
21
+ const PROVIDER_COMMANDS = {
22
+ 'spec-sync': {
23
+ run: specSync,
24
+ usage: 'skitterspec spec-sync <cmd>',
25
+ summary:
26
+ 'One-way sync to Linear (repo -> tracker; opt-in, needs\n' +
27
+ 'specs/.core/linear.config.json). Run it with no args to\n' +
28
+ 'list its subcommands.',
29
+ },
30
+ 'spec-sanitise': {
31
+ run: specSanitise,
32
+ usage: 'skitterspec spec-sanitise',
33
+ summary:
34
+ 'Rewrite spec markdown so no emphasis or link straddles a\n' +
35
+ 'line break. Dry-run; --write to apply.',
36
+ },
37
+ }
38
+
39
+ // The `--help` section for these commands, in the base HELP's column layout:
40
+ // two-space indent, description starting at column 30, continuations aligned.
41
+ const COL = 30
42
+
43
+ function providerHelpSection() {
44
+ const lines = [`Provider commands (${DIST}):`]
45
+ for (const name of Object.keys(PROVIDER_COMMANDS)) {
46
+ const { usage, summary } = PROVIDER_COMMANDS[name]
47
+ const [first, ...rest] = summary.split('\n')
48
+ lines.push(` ${usage.padEnd(COL - 2)}${first}`)
49
+ for (const line of rest) lines.push(`${' '.repeat(COL)}${line}`)
50
+ }
51
+ return lines.join('\n') + '\n'
52
+ }
53
+
54
+ module.exports = { PROVIDER_COMMANDS, providerHelpSection, DIST }
@@ -17,7 +17,7 @@
17
17
  * Shape (see assets/core/linear.config.md for field docs):
18
18
  * {
19
19
  * linear: { teamKey, teamId, projectId },
20
- * intake: { label, bugLabels },
20
+ * intake: { label, bugLabels, hotfixLabels },
21
21
  * mapping: { specFolder, phases, tasks },
22
22
  * states: { backlog, "in-progress", complete, cancelled },
23
23
  * snapshot: { overviewFile },
@@ -53,7 +53,30 @@ const TASK_MAPPINGS = Object.freeze(['checklist', 'none'])
53
53
  // already carries an id keeps projecting either way — one-way sync
54
54
  // has no delete, so withholding a LINKED sub-issue would freeze it
55
55
  // in the tracker rather than remove it.
56
- const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred'])
56
+ // inline — never: each phase becomes a SECTION of the spec issue's own
57
+ // description instead, and the `## Phases` index stays as its table
58
+ // of contents. For work nobody will pick up phase by phase — 250
59
+ // finished specs are 250 issues worth reading and 669 sub-issues
60
+ // worth nobody's attention. Keeps an already-linked phase's
61
+ // sub-issue for the same reason `deferred` does.
62
+ //
63
+ // `mapping.phases` takes one of these as a scalar (one mode for the whole repo)
64
+ // OR a map keyed by lifecycle bucket — `{ "backlog": "subissue", "complete":
65
+ // "deferred" }` — because a repo can want assignable sub-issues for work in
66
+ // flight and something else entirely for work that finished long ago. A bucket
67
+ // the map omits gets DEFAULT_PHASE_MODE, so a partial map adds an exception
68
+ // rather than silently suppressing phases everywhere it is silent.
69
+ const PHASE_MAPPINGS = Object.freeze(['subissue', 'deferred', 'inline'])
70
+ const DEFAULT_PHASE_MODE = 'subissue'
71
+
72
+ // How `spec-sync apply` reaches Linear. `api` talks to the GraphQL API directly;
73
+ // `mcp` prints the plan for the skill to apply over MCP, as it always has.
74
+ const TRANSPORTS = Object.freeze(['api', 'mcp'])
75
+
76
+ // The environment variable a Linear personal API key is read from, unless
77
+ // `auth.keyEnv` names another. The config names the VARIABLE, never the key —
78
+ // nothing secret is ever written to the repo.
79
+ const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
57
80
 
58
81
  const DEFAULT_CONFIG = Object.freeze({
59
82
  // `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
@@ -65,14 +88,18 @@ const DEFAULT_CONFIG = Object.freeze({
65
88
  linear: Object.freeze({ teamKey: '', teamId: '', projectId: '' }),
66
89
  // Issue intake (`/spec <ISSUE-REF>`, `/spec --from-issue`). `label` is the
67
90
  // inbox filter — issues carrying it are what the web app files; `bugLabels`
68
- // route an issue to `/spec-bug` instead of `/spec`. Both empty = no inbox to
69
- // browse (a bare issue ref still works) and no bug routing.
70
- intake: Object.freeze({ label: '', bugLabels: Object.freeze([]) }),
91
+ // route an issue to `/spec-bug` instead of `/spec`, and `hotfixLabels` route it
92
+ // to `/spec-hotfix` — a bug that has to be patched on the released version, not
93
+ // fixed on main. All empty = no inbox to browse (a bare issue ref still works)
94
+ // and no routing. `hotfixLabels` wins over `bugLabels` on an issue carrying
95
+ // both: production is the more specific destination, and the cost of getting it
96
+ // wrong is asymmetric — a fix that lands only on main never reaches prod.
97
+ intake: Object.freeze({ label: '', bugLabels: Object.freeze([]), hotfixLabels: Object.freeze([]) }),
71
98
  // A spec is a Linear ISSUE; each phase is a SUB-ISSUE of it; tasks are not
72
99
  // synced (they live only in the repo phase files).
73
100
  // A spec is an ISSUE; each phase a SUB-ISSUE of it. `tasks` selects how the
74
101
  // phase's checkboxes reach that sub-issue's description — see TASK_MAPPINGS.
75
- mapping: Object.freeze({ specFolder: 'issue', phases: 'subissue', tasks: 'checklist' }),
102
+ mapping: Object.freeze({ specFolder: 'issue', phases: DEFAULT_PHASE_MODE, tasks: 'checklist' }),
76
103
  // Linear ISSUE workflow-state names — the spec issue's state (from the folder
77
104
  // bucket) and each sub-issue's state (from the phase emoji) both map through
78
105
  // this one table. They must match the workspace's issue states exactly;
@@ -85,6 +112,12 @@ const DEFAULT_CONFIG = Object.freeze({
85
112
  }),
86
113
  snapshot: Object.freeze({ overviewFile: '00-overview.md' }),
87
114
  branch: Object.freeze({ pattern: '{type}/{slug}' }),
115
+ // `keyEnv` names the env var holding the personal API key. It is a NAME, not a
116
+ // key: putting the secret itself here would commit it.
117
+ auth: Object.freeze({ keyEnv: DEFAULT_KEY_ENV }),
118
+ // `transport` is the default for `spec-sync apply --via`. Empty means "decide
119
+ // at run time": use the API when a key is present, MCP when it isn't.
120
+ apply: Object.freeze({ transport: '' }),
88
121
  sync: Object.freeze({
89
122
  baseDir: 'specs/.core/linear-base',
90
123
  backupDir: 'specs/.core/linear-backups',
@@ -108,6 +141,11 @@ const DEFAULT_CONFIG = Object.freeze({
108
141
  }),
109
142
  })
110
143
 
144
+ // The lifecycle buckets a per-bucket `mapping.phases` map may key on. Derived
145
+ // from `states` rather than restated: both maps key on the spec's folder bucket,
146
+ // so they cannot drift apart.
147
+ const LIFECYCLE_BUCKETS = Object.freeze(Object.keys(DEFAULT_CONFIG.states))
148
+
111
149
  function isObject(value) {
112
150
  return value !== null && typeof value === 'object' && !Array.isArray(value)
113
151
  }
@@ -116,11 +154,17 @@ function isObject(value) {
116
154
  function defaults() {
117
155
  return {
118
156
  linear: { ...DEFAULT_CONFIG.linear },
119
- intake: { label: DEFAULT_CONFIG.intake.label, bugLabels: [...DEFAULT_CONFIG.intake.bugLabels] },
157
+ intake: {
158
+ label: DEFAULT_CONFIG.intake.label,
159
+ bugLabels: [...DEFAULT_CONFIG.intake.bugLabels],
160
+ hotfixLabels: [...DEFAULT_CONFIG.intake.hotfixLabels],
161
+ },
120
162
  mapping: { ...DEFAULT_CONFIG.mapping },
121
163
  states: { ...DEFAULT_CONFIG.states },
122
164
  snapshot: { ...DEFAULT_CONFIG.snapshot },
123
165
  branch: { ...DEFAULT_CONFIG.branch },
166
+ auth: { ...DEFAULT_CONFIG.auth },
167
+ apply: { ...DEFAULT_CONFIG.apply },
124
168
  sync: {
125
169
  baseDir: DEFAULT_CONFIG.sync.baseDir,
126
170
  backupDir: DEFAULT_CONFIG.sync.backupDir,
@@ -164,6 +208,47 @@ function mergeFieldOwnership(base, parsed) {
164
208
  }
165
209
  }
166
210
 
211
+ // Merge (and validate) `mapping.phases` in either of its two forms: a scalar
212
+ // mode for the whole repo, or a map of lifecycle bucket → mode. Loud on a bad
213
+ // key or value, like fieldOwnership and mapping.tasks — a misspelt bucket would
214
+ // otherwise read as a deliberate default and go on minting the sub-issues the
215
+ // config was written to stop.
216
+ function mergePhaseMapping(base, parsed) {
217
+ const value = parsed.phases
218
+ if (typeof value === 'string') {
219
+ if (value.trim()) base.phases = value.trim()
220
+ } else if (isObject(value)) {
221
+ const byBucket = {}
222
+ for (const [bucket, mode] of Object.entries(value)) {
223
+ if (!LIFECYCLE_BUCKETS.includes(bucket)) {
224
+ throw new Error(
225
+ `Invalid ${CONFIG_FILE}: mapping.phases.${bucket} is not a lifecycle bucket ` +
226
+ `(expected one of ${LIFECYCLE_BUCKETS.join('|')})`,
227
+ )
228
+ }
229
+ if (!PHASE_MAPPINGS.includes(mode)) {
230
+ throw new Error(
231
+ `Invalid ${CONFIG_FILE}: mapping.phases.${bucket} = ${JSON.stringify(mode)} ` +
232
+ `(expected one of ${PHASE_MAPPINGS.join('|')})`,
233
+ )
234
+ }
235
+ byBucket[bucket] = mode
236
+ }
237
+ base.phases = byBucket
238
+ } else if (value !== undefined) {
239
+ throw new Error(
240
+ `Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(value)} ` +
241
+ `(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
242
+ )
243
+ }
244
+ if (typeof base.phases === 'string' && !PHASE_MAPPINGS.includes(base.phases)) {
245
+ throw new Error(
246
+ `Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.phases)} ` +
247
+ `(expected one of ${PHASE_MAPPINGS.join('|')}, or a map of lifecycle bucket to mode)`,
248
+ )
249
+ }
250
+ }
251
+
167
252
  // Merge (and validate) sync.keyedFields. Each value is the item's id property
168
253
  // name (a non-empty string); a field listed here is compared per item.
169
254
  function mergeKeyedFields(base, parsed) {
@@ -197,11 +282,13 @@ function mergeConfig(base, parsed) {
197
282
  if (Array.isArray(parsed.intake.bugLabels)) {
198
283
  base.intake.bugLabels = stringList(parsed.intake.bugLabels)
199
284
  }
285
+ if (Array.isArray(parsed.intake.hotfixLabels)) {
286
+ base.intake.hotfixLabels = stringList(parsed.intake.hotfixLabels)
287
+ }
200
288
  }
201
289
 
202
290
  if (isObject(parsed.mapping)) {
203
291
  assign(base.mapping, parsed.mapping, 'specFolder', 'string')
204
- assign(base.mapping, parsed.mapping, 'phases', 'string')
205
292
  assign(base.mapping, parsed.mapping, 'tasks', 'string')
206
293
  // Loud on a typo, like fieldOwnership above. Quietly falling back would make
207
294
  // a misspelt value look like a deliberate `none` — the same silent
@@ -212,12 +299,7 @@ function mergeConfig(base, parsed) {
212
299
  `(expected one of ${TASK_MAPPINGS.join('|')})`,
213
300
  )
214
301
  }
215
- if (!PHASE_MAPPINGS.includes(base.mapping.phases)) {
216
- throw new Error(
217
- `Invalid ${CONFIG_FILE}: mapping.phases = ${JSON.stringify(base.mapping.phases)} ` +
218
- `(expected one of ${PHASE_MAPPINGS.join('|')})`,
219
- )
220
- }
302
+ mergePhaseMapping(base.mapping, parsed.mapping)
221
303
  }
222
304
 
223
305
  if (isObject(parsed.states)) {
@@ -234,6 +316,22 @@ function mergeConfig(base, parsed) {
234
316
  assign(base.branch, parsed.branch, 'pattern', 'string')
235
317
  }
236
318
 
319
+ if (isObject(parsed.auth)) {
320
+ assign(base.auth, parsed.auth, 'keyEnv', 'string')
321
+ }
322
+
323
+ if (isObject(parsed.apply)) {
324
+ assign(base.apply, parsed.apply, 'transport', 'string?')
325
+ // Loud on a typo, like the mapping enums: a misspelt transport must not
326
+ // quietly fall back to MCP and look like a deliberate choice.
327
+ if (base.apply.transport && !TRANSPORTS.includes(base.apply.transport)) {
328
+ throw new Error(
329
+ `Invalid ${CONFIG_FILE}: apply.transport = ${JSON.stringify(base.apply.transport)} ` +
330
+ `(expected one of ${TRANSPORTS.join('|')})`,
331
+ )
332
+ }
333
+ }
334
+
237
335
  if (isObject(parsed.sync)) {
238
336
  assign(base.sync, parsed.sync, 'baseDir', 'string')
239
337
  assign(base.sync, parsed.sync, 'backupDir', 'string')
@@ -279,9 +377,13 @@ function loadLinearConfig(dir = process.cwd()) {
279
377
  module.exports = {
280
378
  loadLinearConfig,
281
379
  mergeConfig,
380
+ defaults,
282
381
  DEFAULT_CONFIG,
283
382
  CONFIG_FILE,
284
383
  OWNERSHIP,
285
384
  TASK_MAPPINGS,
286
385
  PHASE_MAPPINGS,
386
+ LIFECYCLE_BUCKETS,
387
+ TRANSPORTS,
388
+ DEFAULT_KEY_ENV,
287
389
  }