@skitterbyte/skitterspec-linear 10.1.0 → 10.2.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 +19 -0
- package/package.json +1 -1
- package/src/cli.js +30 -19
- 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 +701 -3
- 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
|
@@ -8,6 +8,25 @@
|
|
|
8
8
|
* (`init`, `update`, `spec-env`, `--help`, …) delegates to the base CLI unchanged.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
// This package's bin/, src/ and assets/ are COMPOSED by scripts/build-dist.js and
|
|
12
|
+
// gitignored, not committed — so a checkout linked before a build (or after a
|
|
13
|
+
// `git clean`) has a working binary with nothing behind it. Say that, instead of
|
|
14
|
+
// letting `require` raise MODULE_NOT_FOUND on an internal path the caller has no
|
|
15
|
+
// way to interpret.
|
|
16
|
+
//
|
|
17
|
+
// Inline rather than shared: a helper would have to live in src/, which is
|
|
18
|
+
// exactly what may be missing. In the workspace packages src/ always exists, so
|
|
19
|
+
// this is inert there.
|
|
20
|
+
const { existsSync } = require('node:fs')
|
|
21
|
+
const { join } = require('node:path')
|
|
22
|
+
if (!existsSync(join(__dirname, '..', 'src'))) {
|
|
23
|
+
console.error(
|
|
24
|
+
'skitterspec-linear: no build output — this package\'s bin/src/assets are composed, not committed.\n' +
|
|
25
|
+
' run "npm run build" in the skitterspec repo, then try again.',
|
|
26
|
+
)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
const { run } = require('../src/cli.js')
|
|
12
31
|
const { specSync } = require('../src/vendor/linear/cli-sync.js')
|
|
13
32
|
const { specSanitise } = require('../src/vendor/linear/cli-sanitise.js')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skitterbyte/skitterspec-linear",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.2.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
|
@@ -190,7 +190,7 @@ function specEnvUp(dir, config, specArg) {
|
|
|
190
190
|
process.stdout.write('Usage: skitterspec spec-env up <spec>\n')
|
|
191
191
|
return
|
|
192
192
|
}
|
|
193
|
-
const spec =
|
|
193
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
194
194
|
|
|
195
195
|
// Live-safe: if this spec is already live on the primary checkout (its branch was
|
|
196
196
|
// branch-switched in by `live take`), a `git worktree add` would fail — the branch
|
|
@@ -357,7 +357,7 @@ function specEnvDown(dir, config, specArg, flags) {
|
|
|
357
357
|
process.stdout.write('Usage: skitterspec spec-env down <spec> [--keep-volumes] [--force]\n')
|
|
358
358
|
return
|
|
359
359
|
}
|
|
360
|
-
const spec =
|
|
360
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
361
361
|
|
|
362
362
|
// A worktree-only spec never held a slot but its worktree still needs removing,
|
|
363
363
|
// so "nothing to do" means neither a slot nor a worktree exists.
|
|
@@ -459,6 +459,31 @@ function liveWorktreePaths(dir) {
|
|
|
459
459
|
return paths
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
// Resolve a spec argument the ONE way every spec-env subcommand resolves it:
|
|
463
|
+
// against the primary checkout first, then the spec's own worktree, then every
|
|
464
|
+
// other checkout git knows about. An in-progress spec is git-mv'd into
|
|
465
|
+
// specs/in-progress/ **on its own branch**, so it exists only in its worktree —
|
|
466
|
+
// a primary-checkout-only lookup fails for exactly the specs these commands
|
|
467
|
+
// serve. The first fallback is the worktree path the config derives for this
|
|
468
|
+
// spec (cheap, no git); the rest come from `git worktree list`, so a worktree
|
|
469
|
+
// provisioned under an older `worktree.root` still resolves. Identity and
|
|
470
|
+
// coordinate tokens always expand against `dir` (the primary checkout), so the
|
|
471
|
+
// answer is identical whether the command was run from main or a worktree.
|
|
472
|
+
function resolveSpecWithWorktree(dir, config, specArg) {
|
|
473
|
+
const { slug } = splitPrefix(path.basename(specArg))
|
|
474
|
+
const { repo, repoSlug } = repoInfo(dir)
|
|
475
|
+
const wtTokens = { repo, repoSlug, slug }
|
|
476
|
+
const worktreeGuess = path.resolve(
|
|
477
|
+
dir,
|
|
478
|
+
expandTokens(config.worktree.root, wtTokens),
|
|
479
|
+
expandTokens(config.worktree.folderPattern, wtTokens),
|
|
480
|
+
)
|
|
481
|
+
const searchDirs = [...new Set([worktreeGuess, ...liveWorktreePaths(dir)])].filter(
|
|
482
|
+
(p) => p !== dir,
|
|
483
|
+
)
|
|
484
|
+
return resolveSpec(specArg, dir, config, { searchDirs })
|
|
485
|
+
}
|
|
486
|
+
|
|
462
487
|
// Every spec folder name found under specs/* across the given checkout roots.
|
|
463
488
|
// An in-progress spec lives on its *worktree branch*, not the primary checkout,
|
|
464
489
|
// so we must scan the worktrees too — otherwise a live spec's DB looks orphaned.
|
|
@@ -771,7 +796,7 @@ function specEnvResolve(dir, config, specArg) {
|
|
|
771
796
|
process.stdout.write('Usage: skitterspec spec-env resolve <spec>\n')
|
|
772
797
|
return
|
|
773
798
|
}
|
|
774
|
-
const r =
|
|
799
|
+
const r = resolveSpecWithWorktree(dir, config, specArg)
|
|
775
800
|
process.stdout.write(
|
|
776
801
|
`spec: ${r.folder} (${r.bucket})\n` +
|
|
777
802
|
`type/slug: ${r.type} / ${r.slug}\n` +
|
|
@@ -792,7 +817,7 @@ async function specEnvDev(dir, config, positional) {
|
|
|
792
817
|
process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
|
|
793
818
|
return
|
|
794
819
|
}
|
|
795
|
-
const spec =
|
|
820
|
+
const spec = resolveSpecWithWorktree(dir, config, specArg)
|
|
796
821
|
if (!config.dev.length) {
|
|
797
822
|
process.stdout.write(
|
|
798
823
|
'spec-env dev: no dev processes configured — set "dev": [...] in env.config.json.\n',
|
|
@@ -888,7 +913,7 @@ async function specEnvConnect(dir, config, specArg) {
|
|
|
888
913
|
return
|
|
889
914
|
}
|
|
890
915
|
|
|
891
|
-
const spec =
|
|
916
|
+
const spec = resolveSpecWithWorktree(dir, config, target)
|
|
892
917
|
const registry = readRegistry(dir, config)
|
|
893
918
|
if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
|
|
894
919
|
process.stdout.write(
|
|
@@ -984,20 +1009,6 @@ async function specEnvLive(dir, config, positional) {
|
|
|
984
1009
|
}
|
|
985
1010
|
}
|
|
986
1011
|
|
|
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
1012
|
// Take the running instance: rebase the spec's branch onto base, free it from its
|
|
1002
1013
|
// worktree, and check it out in the primary checkout so the dev server reloads it.
|
|
1003
1014
|
async function specEnvLiveTake(dir, config, specArg) {
|
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
|
+
}
|