@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
|
@@ -4,28 +4,61 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* The Linear-provider distribution's bin — a superset of the base CLI.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The provider's own commands are routed from ONE table (`src/commands.js`),
|
|
8
|
+
* which also generates their `--help` section; every other command (`init`,
|
|
9
|
+
* `update`, `spec-env`, …) delegates to the base CLI unchanged.
|
|
10
|
+
*
|
|
11
|
+
* `--help` is the exception that has to be handled here rather than delegated:
|
|
12
|
+
* the base prints its own HELP const, which cannot know what a provider adds, so
|
|
13
|
+
* delegating made this distribution report that `spec-sync` did not exist.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
// This package's bin/, src/ and assets/ are COMPOSED by scripts/build-dist.js and
|
|
17
|
+
// gitignored, not committed — so a checkout linked before a build (or after a
|
|
18
|
+
// `git clean`) has a working binary with nothing behind it. Say that, instead of
|
|
19
|
+
// letting `require` raise MODULE_NOT_FOUND on an internal path the caller has no
|
|
20
|
+
// way to interpret.
|
|
21
|
+
//
|
|
22
|
+
// Inline rather than shared: a helper would have to live in src/, which is
|
|
23
|
+
// exactly what may be missing. In the workspace packages src/ always exists, so
|
|
24
|
+
// this is inert there.
|
|
25
|
+
const { existsSync } = require('node:fs')
|
|
26
|
+
const { join } = require('node:path')
|
|
27
|
+
if (!existsSync(join(__dirname, '..', 'src'))) {
|
|
28
|
+
console.error(
|
|
29
|
+
'skitterspec-linear: no build output — this package\'s bin/src/assets are composed, not committed.\n' +
|
|
30
|
+
' run "npm run build" in the skitterspec repo, then try again.',
|
|
31
|
+
)
|
|
32
|
+
process.exit(1)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { run, HELP } = require('../src/cli.js')
|
|
36
|
+
const {
|
|
37
|
+
PROVIDER_COMMANDS,
|
|
38
|
+
providerHelpSection,
|
|
39
|
+
} = require('../src/vendor/linear/commands.js')
|
|
14
40
|
|
|
15
41
|
async function main(argv) {
|
|
16
42
|
const [cmd, ...rest] = argv
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
process.
|
|
43
|
+
|
|
44
|
+
// Base help + what this distribution adds. Matched on the COMMAND SLOT only,
|
|
45
|
+
// never the whole argv: `spec-sanitise --help` must reach that command's own
|
|
46
|
+
// help, not be swallowed by the top-level one.
|
|
47
|
+
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
48
|
+
process.stdout.write(`${HELP}\n${providerHelpSection()}`)
|
|
23
49
|
return
|
|
24
50
|
}
|
|
25
|
-
|
|
26
|
-
|
|
51
|
+
|
|
52
|
+
const provider = PROVIDER_COMMANDS[cmd]
|
|
53
|
+
if (provider) {
|
|
54
|
+
// Propagate the exit code. Dropping it made `spec-sync status
|
|
55
|
+
// --workspace-states` (a bad state name) and `stamp` (a refused write) both
|
|
56
|
+
// look successful to any caller checking $?, which is exactly what the
|
|
57
|
+
// /spec-push skill does before it applies a plan.
|
|
58
|
+
process.exitCode = await provider.run(rest)
|
|
27
59
|
return
|
|
28
60
|
}
|
|
61
|
+
|
|
29
62
|
await run(argv)
|
|
30
63
|
}
|
|
31
64
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.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
|
@@ -48,6 +48,29 @@ const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
|
|
|
48
48
|
|
|
49
49
|
const pkg = require('../package.json')
|
|
50
50
|
|
|
51
|
+
// Commands this (tracker-free) base does NOT ship, and the distribution that
|
|
52
|
+
// does. Without this the base says only "unknown command: spec-sync", which a
|
|
53
|
+
// user correctly reads as "no such feature" — nothing anywhere named the
|
|
54
|
+
// distribution that has it, so they were stranded. Naming Linear here is a
|
|
55
|
+
// diagnostic string, not provider machinery: `init.js` already knows
|
|
56
|
+
// `linear.config.json` and `linear-base/` by name in order to protect them.
|
|
57
|
+
const PROVIDER_COMMANDS = {
|
|
58
|
+
'spec-sync': '@skitterbyte/skitterspec-linear',
|
|
59
|
+
'spec-sanitise': '@skitterbyte/skitterspec-linear',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unknownCommand(cmd) {
|
|
63
|
+
const dist = PROVIDER_COMMANDS[cmd]
|
|
64
|
+
if (dist) {
|
|
65
|
+
return (
|
|
66
|
+
`unknown command: ${cmd} — this is the base distribution, which does not ` +
|
|
67
|
+
`ship it.\n ${cmd} comes from ${dist} (a superset of this package): ` +
|
|
68
|
+
`install that instead.`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return `unknown command: ${cmd} (try --help)`
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
const HELP = `skitterspec — spec-driven-development for Claude Code
|
|
52
75
|
|
|
53
76
|
Usage:
|
|
@@ -190,7 +213,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
190
213
|
process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
|
|
191
214
|
return
|
|
192
215
|
}
|
|
193
|
-
const spec =
|
|
216
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
194
217
|
|
|
195
218
|
// Live-safe: if this spec is already live on the primary checkout (its branch was
|
|
196
219
|
// branch-switched in by `live take`), a `git worktree add` would fail — the branch
|
|
@@ -357,7 +380,7 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
357
380
|
process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
|
|
358
381
|
return
|
|
359
382
|
}
|
|
360
|
-
const spec =
|
|
383
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
361
384
|
|
|
362
385
|
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
363
386
|
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
@@ -459,6 +482,31 @@ function liveWorktreePaths(dir) {
|
|
|
459
482
|
return paths
|
|
460
483
|
}
|
|
461
484
|
|
|
485
|
+
// Resolve a spec argument the ONE way every spec-env subcommand resolves it:
|
|
486
|
+
// against the primary checkout first, then the spec's own worktree, then every
|
|
487
|
+
// other checkout git knows about. An in-progress spec is git-mv'd into
|
|
488
|
+
// specs/in-progress/ **on its own branch**, so it exists only in its worktree —
|
|
489
|
+
// a primary-checkout-only lookup fails for exactly the specs these commands
|
|
490
|
+
// serve. The first fallback is the worktree path the config derives for this
|
|
491
|
+
// spec (cheap, no git); the rest come from `git worktree list`, so a worktree
|
|
492
|
+
// provisioned under an older `worktree.root` still resolves. Identity and
|
|
493
|
+
// coordinate tokens always expand against `dir` (the primary checkout), so the
|
|
494
|
+
// answer is identical whether the command was run from main or a worktree.
|
|
495
|
+
function resolveSpecWithWorktree(dir, config, specArg) {
|
|
496
|
+
const { slug } = splitPrefix(path.basename(specArg))
|
|
497
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
498
|
+
const wtTokens = { repo, repoSlug, slug }
|
|
499
|
+
const worktreeGuess = path.resolve(
|
|
500
|
+
dir,
|
|
501
|
+
expandTokens(config.worktree.root, wtTokens),
|
|
502
|
+
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
503
|
+
)
|
|
504
|
+
const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(dir)])].filter(
|
|
505
|
+
(p) => p !== dir,
|
|
506
|
+
)
|
|
507
|
+
return resolveSpec(specArg, dir, config, { searchDirs })
|
|
508
|
+
}
|
|
509
|
+
|
|
462
510
|
// Every spec folder name found under specs/* across the given checkout roots.
|
|
463
511
|
// An in-progress spec lives on its *worktree branch*, not the primary checkout,
|
|
464
512
|
// so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
|
|
@@ -771,7 +819,7 @@ function specEnvResolve(dir, config, specArg) {
|
|
|
771
819
|
process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
|
|
772
820
|
return
|
|
773
821
|
}
|
|
774
|
-
const r =
|
|
822
|
+
const r = resolveSpecWithWorktree(dir, config, specArg)
|
|
775
823
|
process.stdout.write(
|
|
776
824
|
`spec: ${r.folder} (${r.bucket})\n` +
|
|
777
825
|
`type/slug: ${r.type} / ${r.slug}\n` +
|
|
@@ -792,7 +840,7 @@ async function specEnvDev(dir, config, positional) {
|
|
|
792
840
|
process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
|
|
793
841
|
return
|
|
794
842
|
}
|
|
795
|
-
const spec =
|
|
843
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
796
844
|
if (!config.dev.length) {
|
|
797
845
|
process.stdout.write(
|
|
798
846
|
'spec-env dev: no dev processes configured — set "dev": [...] in env.config.json.\n',
|
|
@@ -888,7 +936,7 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
888
936
|
return
|
|
889
937
|
}
|
|
890
938
|
|
|
891
|
-
const spec =
|
|
939
|
+
const spec = resolveSpecWithWorktree(dir, config, target)
|
|
892
940
|
const registry = readRegistry(dir, config)
|
|
893
941
|
if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
|
|
894
942
|
process.stdout.write(
|
|
@@ -984,20 +1032,6 @@ async function specEnvLive(dir, config, positional) {
|
|
|
984
1032
|
}
|
|
985
1033
|
}
|
|
986
1034
|
|
|
987
|
-
// Resolve a spec, offering its worktree as a fallback search dir — a spec authored
|
|
988
|
-
// on its own branch may not exist in the primary checkout's specs/**.
|
|
989
|
-
function resolveSpecWithWorktree(dir, config, specArg) {
|
|
990
|
-
const { slug } = splitPrefix(path.basename(specArg))
|
|
991
|
-
const { repo, repoSlug } = repoInfo(dir)
|
|
992
|
-
const wtTokens = { repo, repoSlug, slug }
|
|
993
|
-
const worktreeGuess = path.resolve(
|
|
994
|
-
dir,
|
|
995
|
-
expandTokens(config.worktree.root, wtTokens),
|
|
996
|
-
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
997
|
-
)
|
|
998
|
-
return resolveSpec(specArg, dir, config, { searchDirs: [worktreeGuess] })
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
1035
|
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
1002
1036
|
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
1003
1037
|
async function specEnvLiveTake(dir, config, specArg) {
|
|
@@ -1373,8 +1407,8 @@ async function run(argv) {
|
|
|
1373
1407
|
await cleanupReleaseTooling(dir, opts)
|
|
1374
1408
|
break
|
|
1375
1409
|
default:
|
|
1376
|
-
throw new Error(
|
|
1410
|
+
throw new Error(unknownCommand(cmd))
|
|
1377
1411
|
}
|
|
1378
1412
|
}
|
|
1379
1413
|
|
|
1380
|
-
module.exports = { run, parse }
|
|
1414
|
+
module.exports = { run, parse, HELP, unknownCommand }
|
package/src/env/resolve.js
CHANGED
|
@@ -217,9 +217,14 @@ function assertPrimaryOnMain(config, git) {
|
|
|
217
217
|
* (the primary checkout), so a worktree-only spec resolves to the right base.
|
|
218
218
|
*/
|
|
219
219
|
function resolveSpec(specArg, dir, config, opts = {}) {
|
|
220
|
-
const
|
|
220
|
+
const searchDirs = opts.searchDirs || []
|
|
221
|
+
const found = findSpecFolder(specArg, dir, searchDirs)
|
|
221
222
|
if (!found) {
|
|
222
|
-
|
|
223
|
+
// Name the roots we looked under: the usual cause is a spec that only exists
|
|
224
|
+
// on its own branch, and the message should say where we didn't find it.
|
|
225
|
+
throw new Error(
|
|
226
|
+
`spec not found under specs/**: ${specArg} (searched: ${[dir, ...searchDirs].join(', ')})`,
|
|
227
|
+
)
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
const { type, slug } = splitPrefix(found.folder)
|
package/src/env/teardown.js
CHANGED
|
@@ -33,7 +33,10 @@ function planDown(spec, config, flags, ctx) {
|
|
|
33
33
|
// A hotfix lands by tag + cherry-pick, so its branch is never an ancestor of
|
|
34
34
|
// base — but once its head is captured by a tag (the deploy tag from
|
|
35
35
|
// `hotfix land`), the commits are recoverable and the branch is safe to drop.
|
|
36
|
-
// Treat "reachable from a tag" as landed, alongside merged.
|
|
36
|
+
// Treat "reachable from a tag" as landed, alongside merged. Read twice below:
|
|
37
|
+
// it decides whether the unpushed guard blocks, and whether the branch delete
|
|
38
|
+
// can use `-D` — the same question ("are these commits recoverable?"), so the
|
|
39
|
+
// two must never answer it differently.
|
|
37
40
|
const landed = Boolean(worktreeState.merged || worktreeState.reachableFromTag)
|
|
38
41
|
|
|
39
42
|
// --- guards (overridable with --force) ---
|
|
@@ -88,15 +91,26 @@ function planDown(spec, config, flags, ctx) {
|
|
|
88
91
|
)
|
|
89
92
|
|
|
90
93
|
// --- delete the branch ---
|
|
91
|
-
// Runs after the worktree remove frees the branch.
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
94
|
+
// Runs after the worktree remove frees the branch. `-D` exactly when we have
|
|
95
|
+
// PROVEN the commits survive the delete, `-d` otherwise.
|
|
96
|
+
//
|
|
97
|
+
// `-d` looks like the safe default and mostly is, but its refusal answers a
|
|
98
|
+
// different question from ours: it also declines a branch that is ahead of its
|
|
99
|
+
// upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
|
|
100
|
+
// even though it is merged to HEAD`. That fires on the ordinary spec flow —
|
|
101
|
+
// `/spec-go` pushes the branch when it provisions, and the phase commits after
|
|
102
|
+
// it are landed locally rather than pushed — so teardown meets a branch whose
|
|
103
|
+
// every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
|
|
104
|
+
// of base) already establishes what we actually care about, and establishes it
|
|
105
|
+
// more strongly than `-d` checks.
|
|
106
|
+
//
|
|
107
|
+
// `reachableFromTag` is the same argument for a hotfix: never an ancestor of
|
|
108
|
+
// base, but its head is captured by the deploy tag.
|
|
109
|
+
//
|
|
110
|
+
// Everything else keeps `-d`, so a forced teardown of a genuinely unlanded
|
|
111
|
+
// branch fails loudly and the skill relays it rather than -D-ing.
|
|
97
112
|
if (spec.branch) {
|
|
98
|
-
|
|
99
|
-
commands.push(`git branch ${tagLanded ? '-D' : '-d'} ${spec.branch}`)
|
|
113
|
+
commands.push(`git branch ${landed ? '-D' : '-d'} ${spec.branch}`)
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
return { blocked: false, reason: null, commands, backupCommand, backupPath, volumesDropped }
|
package/src/init.js
CHANGED
|
@@ -558,12 +558,22 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
558
558
|
' at /spec-go (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
|
|
559
559
|
: 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
|
|
560
560
|
' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
|
|
561
|
+
// A provider superset ships its own `spec-<provider>-setup` skill; the base
|
|
562
|
+
// ships none. Discovering it from what was actually installed keeps this file
|
|
563
|
+
// tracker-free — it never has to know which tracker (if any) is in the box.
|
|
564
|
+
const setupSkill = SKILLS.find((s) => /^spec-.+-setup$/.test(s))
|
|
565
|
+
const trackerNote = setupSkill
|
|
566
|
+
? `Tracker sync is opt-in: run /${setupSkill} to configure it` +
|
|
567
|
+
' (it discovers your workspace and writes the config), or see' +
|
|
568
|
+
' specs/.core/SETUP.md.\n'
|
|
569
|
+
: ''
|
|
561
570
|
process.stdout.write(
|
|
562
571
|
'\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
|
|
563
572
|
' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
|
|
564
573
|
'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
|
|
565
574
|
" project's stack, then run /spec.\n" +
|
|
566
|
-
isolationNote
|
|
575
|
+
isolationNote +
|
|
576
|
+
trackerNote,
|
|
567
577
|
)
|
|
568
578
|
}
|
|
569
579
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Linear GraphQL boundary — the one place that knows concrete Linear API
|
|
5
|
+
* shapes, exactly as `mcp.js` is the one place that knows concrete tool names.
|
|
6
|
+
*
|
|
7
|
+
* Both fulfil the SAME typed operation contract (`makeAdapter`'s return), so
|
|
8
|
+
* `spec-sync apply` is written once against an adapter and never learns which
|
|
9
|
+
* transport it got. That is the whole point: the MCP path stays supported, and
|
|
10
|
+
* the API path exists because routing every issue description through the model
|
|
11
|
+
* as generated tokens makes push throughput a function of decode speed rather
|
|
12
|
+
* than of Linear's API.
|
|
13
|
+
*
|
|
14
|
+
* Auth: a personal API key goes in `Authorization` **raw, with no `Bearer`
|
|
15
|
+
* prefix** — verified against Linear's own docs (https://linear.app/developers/graphql),
|
|
16
|
+
* which is the opposite of the OAuth access-token convention. Sending
|
|
17
|
+
* `Bearer lin_api_…` fails authentication.
|
|
18
|
+
*
|
|
19
|
+
* `fetch` is injected so tests exercise every operation offline and
|
|
20
|
+
* deterministically; production passes the global.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { DEFAULT_KEY_ENV } = require('./config.js')
|
|
24
|
+
|
|
25
|
+
const ENDPOINT = 'https://api.linear.app/graphql'
|
|
26
|
+
|
|
27
|
+
// Rate-limit handling. Enough retries to ride out a burst without turning a
|
|
28
|
+
// genuinely stuck run into an unbounded one.
|
|
29
|
+
const MAX_RETRIES = 5
|
|
30
|
+
const MAX_BACKOFF_MS = 60_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the personal API key from the environment.
|
|
34
|
+
*
|
|
35
|
+
* Returns `{ ok: true, key, envVar }`, or `{ ok: false, envVar, error }` when
|
|
36
|
+
* unset — a value the caller branches on rather than an exception, because "no
|
|
37
|
+
* key" is a normal state that means "use MCP", not a failure.
|
|
38
|
+
*
|
|
39
|
+
* The key is never part of the returned error, and callers must keep it out of
|
|
40
|
+
* logs, plans, snapshots and stamped frontmatter.
|
|
41
|
+
*/
|
|
42
|
+
function resolveApiKey(config, env = process.env) {
|
|
43
|
+
const envVar = (config && config.auth && config.auth.keyEnv) || DEFAULT_KEY_ENV
|
|
44
|
+
const key = env[envVar]
|
|
45
|
+
if (typeof key !== 'string' || !key.trim()) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
envVar,
|
|
49
|
+
error: `no Linear API key — set ${envVar}, or apply the plan over MCP with --via mcp`,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, key: key.trim(), envVar }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fields we read back on every write. `identifier` and `url` are what the skill
|
|
56
|
+
// stamps into the spec; `description` is what `spec-sync verify` compares.
|
|
57
|
+
const ISSUE_FIELDS = 'id identifier url title description state { id name }'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A GraphQL caller bound to one key. Throws a clear Error on transport failure,
|
|
61
|
+
* on an HTTP error, and on a GraphQL `errors` payload — an `apply` that half
|
|
62
|
+
* succeeds must be loud, never silent.
|
|
63
|
+
*/
|
|
64
|
+
function makeClient({ apiKey, fetch: fetchImpl, endpoint = ENDPOINT, sleep, maxRetries = MAX_RETRIES }) {
|
|
65
|
+
const doFetch = fetchImpl || globalThis.fetch
|
|
66
|
+
if (typeof doFetch !== 'function') {
|
|
67
|
+
throw new Error('no fetch available — Node 18+ is required for the API transport')
|
|
68
|
+
}
|
|
69
|
+
// Injected so the retry path is testable without real delays.
|
|
70
|
+
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
71
|
+
|
|
72
|
+
return async function call(query, variables) {
|
|
73
|
+
for (let attempt = 0; ; attempt++) {
|
|
74
|
+
let res
|
|
75
|
+
try {
|
|
76
|
+
res = await doFetch(endpoint, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
// Raw, no `Bearer` — see the module comment.
|
|
81
|
+
Authorization: apiKey,
|
|
82
|
+
},
|
|
83
|
+
body: JSON.stringify({ query, variables }),
|
|
84
|
+
})
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
throw new Error(`Linear API unreachable: ${cause && cause.message ? cause.message : cause}`)
|
|
87
|
+
}
|
|
88
|
+
if (res.status === 401 || res.status === 403) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Linear rejected the API key (HTTP ${res.status}) — check the value of your key environment variable`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
// Rate limited. A bulk backfill is exactly the workload that hits this, and
|
|
94
|
+
// failing the run would strand a half-mirrored bucket — so wait and retry
|
|
95
|
+
// rather than surfacing it. Linear says how long to wait; honour that over
|
|
96
|
+
// guessing, and fall back to exponential backoff when it doesn't.
|
|
97
|
+
if (res.status === 429 && attempt < maxRetries) {
|
|
98
|
+
await wait(retryDelay(res, attempt))
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (res.status === 429) {
|
|
102
|
+
throw new Error(`Linear rate-limited this request and did not recover after ${maxRetries} retries`)
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok) throw new Error(`Linear API returned HTTP ${res.status}`)
|
|
105
|
+
const body = await res.json()
|
|
106
|
+
if (body && Array.isArray(body.errors) && body.errors.length) {
|
|
107
|
+
throw new Error(`Linear API error: ${body.errors.map((e) => e.message).join('; ')}`)
|
|
108
|
+
}
|
|
109
|
+
return body && body.data
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// How long to wait before retrying a 429: the server's `Retry-After` (seconds)
|
|
115
|
+
// when it sends one, else exponential backoff from 1s.
|
|
116
|
+
function retryDelay(res, attempt) {
|
|
117
|
+
const header = res.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null
|
|
118
|
+
const seconds = header != null ? Number(header) : NaN
|
|
119
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, MAX_BACKOFF_MS)
|
|
120
|
+
return Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wrap a GraphQL client into the typed ops the engine uses — the same set
|
|
125
|
+
* `mcp.js`'s `makeAdapter` returns, so the two are interchangeable.
|
|
126
|
+
*/
|
|
127
|
+
function makeApiAdapter({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries } = {}) {
|
|
128
|
+
const call = makeClient({ apiKey, fetch: fetchImpl, endpoint, sleep, maxRetries })
|
|
129
|
+
|
|
130
|
+
// Issue mutations return the created/updated issue under a payload wrapper.
|
|
131
|
+
const unwrap = (data, field) => (data && data[field] && data[field].issue) || null
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async readIssue(id) {
|
|
135
|
+
const data = await call(`query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`, { id })
|
|
136
|
+
return (data && data.issue) || null
|
|
137
|
+
},
|
|
138
|
+
async createIssue(issue) {
|
|
139
|
+
const data = await call(
|
|
140
|
+
`mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`,
|
|
141
|
+
{ input: issue },
|
|
142
|
+
)
|
|
143
|
+
return unwrap(data, 'issueCreate')
|
|
144
|
+
},
|
|
145
|
+
async updateIssue(id, updates) {
|
|
146
|
+
const data = await call(
|
|
147
|
+
`mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`,
|
|
148
|
+
{ id, input: updates },
|
|
149
|
+
)
|
|
150
|
+
return unwrap(data, 'issueUpdate')
|
|
151
|
+
},
|
|
152
|
+
// A phase sub-issue is an ordinary issue carrying `parentId`. Same mutation,
|
|
153
|
+
// so there is no separate sub-issue surface to keep in step.
|
|
154
|
+
async createSubIssue(parentId, subIssue) {
|
|
155
|
+
return this.createIssue({ ...subIssue, parentId })
|
|
156
|
+
},
|
|
157
|
+
async updateSubIssue(id, updates) {
|
|
158
|
+
return this.updateIssue(id, updates)
|
|
159
|
+
},
|
|
160
|
+
async listSubIssues(parentId) {
|
|
161
|
+
const data = await call(
|
|
162
|
+
`query($id: String!) { issue(id: $id) { children { nodes { ${ISSUE_FIELDS} } } } }`,
|
|
163
|
+
{ id: parentId },
|
|
164
|
+
)
|
|
165
|
+
return (data && data.issue && data.issue.children && data.issue.children.nodes) || []
|
|
166
|
+
},
|
|
167
|
+
// The intake inbox. A free-text term uses Linear's `searchIssues` query; a
|
|
168
|
+
// label/team filter alone uses `issues`. Two queries rather than one because
|
|
169
|
+
// Linear exposes text search as its own root field, not an `IssueFilter` key.
|
|
170
|
+
async searchIssues({ label, query, teamId } = {}) {
|
|
171
|
+
const filter = {}
|
|
172
|
+
if (label) filter.labels = { name: { eq: label } }
|
|
173
|
+
if (teamId) filter.team = { id: { eq: teamId } }
|
|
174
|
+
if (query) {
|
|
175
|
+
const data = await call(
|
|
176
|
+
`query($term: String!, $filter: IssueFilter) {
|
|
177
|
+
searchIssues(term: $term, filter: $filter) { nodes { ${ISSUE_FIELDS} } } }`,
|
|
178
|
+
{ term: query, filter },
|
|
179
|
+
)
|
|
180
|
+
return (data && data.searchIssues && data.searchIssues.nodes) || []
|
|
181
|
+
}
|
|
182
|
+
const data = await call(
|
|
183
|
+
`query($filter: IssueFilter) { issues(filter: $filter) { nodes { ${ISSUE_FIELDS} } } }`,
|
|
184
|
+
{ filter },
|
|
185
|
+
)
|
|
186
|
+
return (data && data.issues && data.issues.nodes) || []
|
|
187
|
+
},
|
|
188
|
+
async listProjects(teamId) {
|
|
189
|
+
const data = teamId
|
|
190
|
+
? await call(`query($id: String!) { team(id: $id) { projects { nodes { id name } } } }`, { id: teamId })
|
|
191
|
+
: await call('query { projects { nodes { id name } } }')
|
|
192
|
+
if (data && data.team) return (data.team.projects && data.team.projects.nodes) || []
|
|
193
|
+
return (data && data.projects && data.projects.nodes) || []
|
|
194
|
+
},
|
|
195
|
+
// The workspace's issue workflow states, in the shape `--workspace-states`
|
|
196
|
+
// already accepts, so the existing state check is reused rather than forked.
|
|
197
|
+
async listIssueStates(teamId) {
|
|
198
|
+
const data = teamId
|
|
199
|
+
? await call(`query($id: String!) { team(id: $id) { states { nodes { id name type } } } }`, { id: teamId })
|
|
200
|
+
: await call('query { workflowStates { nodes { id name type } } }')
|
|
201
|
+
if (data && data.team) return (data.team.states && data.team.states.nodes) || []
|
|
202
|
+
return (data && data.workflowStates && data.workflowStates.nodes) || []
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The workspace's state NAMES, for `validateStates` — the same array
|
|
209
|
+
* `--workspace-states` carries on the MCP path. On the API path nobody has to
|
|
210
|
+
* fetch it by hand.
|
|
211
|
+
*/
|
|
212
|
+
async function fetchWorkspaceStates(adapter, teamId) {
|
|
213
|
+
const states = await adapter.listIssueStates(teamId)
|
|
214
|
+
return states.map((s) => s && s.name).filter((n) => typeof n === 'string' && n)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Map a local lifecycle bucket to a Linear state ID.
|
|
219
|
+
*
|
|
220
|
+
* MCP accepted a state NAME; the GraphQL API needs an id, so this is the one
|
|
221
|
+
* extra hop the API transport adds. Resolved against the states actually fetched
|
|
222
|
+
* from the workspace, so a `config.states` value the workspace lacks fails here
|
|
223
|
+
* with the same vocabulary the state check already uses.
|
|
224
|
+
*/
|
|
225
|
+
function stateIdFor(bucket, config, states) {
|
|
226
|
+
const name = config && config.states && config.states[bucket]
|
|
227
|
+
if (!name) return null
|
|
228
|
+
const hit = states.find((s) => s && s.name === name)
|
|
229
|
+
if (!hit) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`no Linear state named ${JSON.stringify(name)} for bucket ${JSON.stringify(bucket)} — ` +
|
|
232
|
+
`workspace has: ${states.map((s) => s.name).join(', ')}`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
return hit.id
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
ENDPOINT,
|
|
240
|
+
MAX_RETRIES,
|
|
241
|
+
resolveApiKey,
|
|
242
|
+
makeClient,
|
|
243
|
+
makeApiAdapter,
|
|
244
|
+
fetchWorkspaceStates,
|
|
245
|
+
stateIdFor,
|
|
246
|
+
}
|