@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.
- package/README.md +68 -23
- package/assets/core/SETUP.md +73 -13
- package/assets/core/linear.config.json.example +8 -1
- package/assets/core/linear.config.md +177 -10
- package/assets/rules/spec-planning.md +17 -11
- package/assets/skills/spec/SKILL.md +106 -66
- package/assets/skills/spec-bug/SKILL.md +99 -17
- package/assets/skills/spec-cancel/SKILL.md +34 -0
- package/assets/skills/spec-complete/SKILL.md +70 -15
- package/assets/skills/spec-hotfix/SKILL.md +157 -4
- package/assets/skills/spec-linear-setup/SKILL.md +172 -0
- package/assets/skills/spec-push/SKILL.md +108 -32
- package/assets/skills/spec-review/SKILL.md +34 -0
- package/assets/skills/spec-status/SKILL.md +9 -0
- package/bin/skitterspec-linear.js +46 -13
- package/package.json +1 -1
- package/src/cli.js +55 -21
- package/src/env/resolve.js +7 -2
- package/src/env/teardown.js +23 -9
- package/src/init.js +11 -1
- package/src/vendor/linear/api.js +246 -0
- package/src/vendor/linear/cli-sync.js +714 -11
- package/src/vendor/linear/commands.js +54 -0
- package/src/vendor/linear/config.js +116 -14
- package/src/vendor/sync-core/src/normalize.js +232 -85
- package/src/vendor/sync-core/src/push.js +10 -1
- package/src/vendor/sync-core/src/task-block.js +18 -7
|
@@ -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
|
-
|
|
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
|
|
69
|
-
//
|
|
70
|
-
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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
|
}
|