@kb1slnlabs/dsh-linear 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +408 -2
- package/THIRD_PARTY_NOTICES.md +19 -0
- package/cordis.patch.yml +19 -0
- package/index.js +1460 -0
- package/package.json +40 -4
package/index.js
ADDED
|
@@ -0,0 +1,1460 @@
|
|
|
1
|
+
// @kb1slnlabs/dsh-linear — Linear issue-tracking tools for the DeepSeek Harness.
|
|
2
|
+
//
|
|
3
|
+
// A Cordis function/namespace plugin: exports { name, inject, apply } (NO default
|
|
4
|
+
// export — a stray default collapses the module via the Loader's unwrapExports).
|
|
5
|
+
// `apply` registers model-facing tools through ctx.tools.register.
|
|
6
|
+
//
|
|
7
|
+
// One tool surface:
|
|
8
|
+
// GraphQL tools (linear_*): 26 hand-written tools that talk to the Linear
|
|
9
|
+
// GraphQL API (https://api.linear.app/graphql) with a personal API token
|
|
10
|
+
// (env LINEAR_API_KEY or config apiKey). There is no MCP server and no MCP
|
|
11
|
+
// client here — no connection to mcp.linear.app is ever opened, and no MCP
|
|
12
|
+
// configuration exists. The 26 tools below are the entire surface.
|
|
13
|
+
//
|
|
14
|
+
// Peer deps (@deepseek-ai/dsh-tools, @deepseek-ai/cordis) resolve
|
|
15
|
+
// from the dsh install's node_modules — this file lives outside any pnpm tree,
|
|
16
|
+
// so a plain import would not find them; the resolvers below pin resolution to
|
|
17
|
+
// the dsh install root by absolute file:// URL.
|
|
18
|
+
//
|
|
19
|
+
// GraphQL tools:
|
|
20
|
+
// linear_issue_list — list issues (filter by team, state, search, limit, cursor pagination)
|
|
21
|
+
// linear_issue_get — one issue by id or identifier (e.g. "APT-123")
|
|
22
|
+
// linear_issue_create — create an issue in a team
|
|
23
|
+
// linear_issue_update — update an issue (state, description, assignee, labels, priority)
|
|
24
|
+
// linear_issue_archive — archive an issue (id or identifier)
|
|
25
|
+
// linear_issue_unarchive — unarchive a previously archived issue
|
|
26
|
+
// linear_comment_add — add a comment to an issue
|
|
27
|
+
// linear_comment_update — update a comment body (comment UUID)
|
|
28
|
+
// linear_comment_delete — delete a comment (comment UUID)
|
|
29
|
+
// linear_comment_list — list comments on an issue (cursor pagination)
|
|
30
|
+
// linear_relation_add — link a related issue to an issue
|
|
31
|
+
// linear_team_list — list teams
|
|
32
|
+
// linear_team_get — one team by id (UUID) or key
|
|
33
|
+
// linear_state_list — list workflow states for a team
|
|
34
|
+
// linear_label_list — list labels for a team
|
|
35
|
+
// linear_label_get — one label by id (UUID)
|
|
36
|
+
// linear_label_create — create a label for a team
|
|
37
|
+
// linear_project_list — list projects
|
|
38
|
+
// linear_project_get — one project by id (UUID)
|
|
39
|
+
// linear_project_create — create a project
|
|
40
|
+
// linear_project_update — update a project (name, description, content, dates, teams)
|
|
41
|
+
// linear_cycle_list — list cycles for a team
|
|
42
|
+
// linear_cycle_get — one cycle by id (UUID)
|
|
43
|
+
// linear_user_list — list users in the workspace
|
|
44
|
+
// linear_user_get — one user by id (UUID) or email
|
|
45
|
+
// linear_graphql — direct GraphQL escape hatch (caller's own query or
|
|
46
|
+
// mutation document; coverage is whatever the Linear
|
|
47
|
+
// API exposes to the key, not named-tool parity)
|
|
48
|
+
|
|
49
|
+
import { createRequire } from 'node:module'
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
51
|
+
import { homedir } from 'node:os'
|
|
52
|
+
import { dirname, join } from 'node:path'
|
|
53
|
+
|
|
54
|
+
// The plugin source lives outside any pnpm tree, so a bare import('@deepseek-ai/dsh-tools')
|
|
55
|
+
// cannot resolve from here. Every dependency is therefore requested as a STANDARD bare
|
|
56
|
+
// specifier ('@deepseek-ai/dsh-tools', '@deepseek-ai/schemastery') and resolved through the
|
|
57
|
+
// packages' own "exports" maps — never through a hardcoded entry filename. Node's own resolver
|
|
58
|
+
// walks up from this file, so
|
|
59
|
+
// when the plugin is installed inside a tree that carries these packages (or one of their
|
|
60
|
+
// peer locations), plain resolution finds them with no anchoring at all. Only when that
|
|
61
|
+
// fails do we fall back to resolving from the dsh install root, derived portably from
|
|
62
|
+
// process.execPath (the npm-global prefix layout on any platform), and only as a last
|
|
63
|
+
// resort is the historical Windows %APPDATA% location probed.
|
|
64
|
+
//
|
|
65
|
+
// We use import() (NOT require()): a synchronous require() of dsh-tools — an ESM module —
|
|
66
|
+
// at module-eval time races the harness's own concurrent import() of the same module and
|
|
67
|
+
// trips Node's ERR_INTERNAL_ASSERTION ("module status 0 ... not yet fully loaded").
|
|
68
|
+
// import() of an absolute file:// URL awaits the same module job and is race-free.
|
|
69
|
+
//
|
|
70
|
+
// Resolution order for every dependency below (identical shape):
|
|
71
|
+
// 1. the DSH_LINEAR_* override env var (explicit entry, any resolvable path/URL)
|
|
72
|
+
// 2. plain Node resolution of the bare specifier from this file
|
|
73
|
+
// 3. the same bare specifier resolved from the dsh install root
|
|
74
|
+
|
|
75
|
+
/** Resolve a bare specifier to a file:// URL, or undefined if nothing provides it. */
|
|
76
|
+
function tryResolveFrom(fromFile, specifier) {
|
|
77
|
+
try {
|
|
78
|
+
return pathToFileURL(createRequire(fromFile).resolve(specifier)).href
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** This file's path — the anchor for plain (package.json-driven) resolution. */
|
|
85
|
+
const selfPath = fileURLToPath(import.meta.url)
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Anchor for peer resolution inside a dsh install: the installed @deepseek-ai/dsh package
|
|
89
|
+
* itself, located by walking up from process.execPath to the nearest ancestor that carries
|
|
90
|
+
* .../node_modules/@deepseek-ai/dsh/package.json. execPath-based (not a hardcoded home
|
|
91
|
+
* directory) so it works for any npm/pnpm global prefix on Windows, macOS or Linux.
|
|
92
|
+
*/
|
|
93
|
+
function dshInstallAnchor() {
|
|
94
|
+
let dir = dirname((process.execPath || '').replace(/[\\/]+$/, '') || '.')
|
|
95
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
96
|
+
const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
|
|
97
|
+
if (tryResolveFrom(join(dir, 'noop.js'), '@deepseek-ai/dsh/package.json')) return candidate
|
|
98
|
+
const parent = dirname(dir)
|
|
99
|
+
if (parent === dir) break
|
|
100
|
+
dir = parent
|
|
101
|
+
}
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Portable fallback anchors for the dsh install's node_modules, in preference order:
|
|
107
|
+
* 1. npm/pnpm global prefix derived from process.execPath (<prefix>/node_modules)
|
|
108
|
+
* 2. %APPDATA%/npm/node_modules (the historical Windows npm-global location)
|
|
109
|
+
* 3. /usr/local/lib/node_modules and /usr/lib/node_modules (POSIX npm-global defaults)
|
|
110
|
+
*/
|
|
111
|
+
function fallbackNodeModulesRoots() {
|
|
112
|
+
const roots = []
|
|
113
|
+
const execPath = (process.execPath || '').replace(/\\/g, '/')
|
|
114
|
+
const marker = '/node_modules/'
|
|
115
|
+
const idx = execPath.lastIndexOf(marker)
|
|
116
|
+
if (idx !== -1) roots.push(execPath.slice(0, idx + '/node_modules'.length))
|
|
117
|
+
if (process.platform === 'win32') {
|
|
118
|
+
roots.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'node_modules'))
|
|
119
|
+
} else {
|
|
120
|
+
roots.push('/usr/local/lib/node_modules', '/usr/lib/node_modules')
|
|
121
|
+
}
|
|
122
|
+
return roots
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve one bare specifier: plain resolution from this file first, then the dsh install
|
|
127
|
+
* anchor, then each fallback node_modules root. `override` (a DSH_LINEAR_* env var) wins
|
|
128
|
+
* over all of them when set. The final entry is the resolved file:// URL, so import() stays
|
|
129
|
+
* race-free; if nothing resolves, the plain specifier is returned so import() raises Node's
|
|
130
|
+
* own module-not-found error.
|
|
131
|
+
*
|
|
132
|
+
* Peer dependencies are declared as BOTH `dependencies` and `peerDependencies`, because
|
|
133
|
+
* either location may be what Node finds — a copy installed next to this plugin, or the host
|
|
134
|
+
* dsh install's copy.
|
|
135
|
+
*/
|
|
136
|
+
function resolveEntry(override, specifier) {
|
|
137
|
+
if (override) return override
|
|
138
|
+
return tryResolveFrom(selfPath, specifier) ?? tryResolveFrom(dshInstallAnchor(), specifier) ?? tryResolveFromFromRoots(specifier) ?? specifier
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Last resort: the same bare specifier resolved from each npm-global fallback root. */
|
|
142
|
+
function tryResolveFromFromRoots(specifier) {
|
|
143
|
+
for (const root of fallbackNodeModulesRoots()) {
|
|
144
|
+
const entry = tryResolveFrom(join(root, 'noop.js'), specifier)
|
|
145
|
+
if (entry !== undefined) return entry
|
|
146
|
+
}
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveDshToolsEntry() {
|
|
151
|
+
return resolveEntry(process.env.DSH_LINEAR_TOOLS, '@deepseek-ai/dsh-tools')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Same resolution chain, but for the schemastery schema lib (used to declare the plugin Config).
|
|
155
|
+
function resolveDshSchemasteryEntry() {
|
|
156
|
+
return resolveEntry(process.env.DSH_LINEAR_SCHEMA, '@deepseek-ai/schemastery')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { defineTool } = await import(resolveDshToolsEntry())
|
|
160
|
+
// schemastery's exports map serves the ESM entry to an import in a tree that provides it;
|
|
161
|
+
// resolved from the dsh install root the same specifier may land on its CJS entry, which
|
|
162
|
+
// Node wraps with both `default` and the named bindings. Take whichever shape is real.
|
|
163
|
+
const schemastery = await import(resolveDshSchemasteryEntry())
|
|
164
|
+
const z = typeof schemastery.default?.object === 'function' ? schemastery.default : schemastery
|
|
165
|
+
|
|
166
|
+
const name = 'linear'
|
|
167
|
+
const inject = ['tools']
|
|
168
|
+
|
|
169
|
+
const API_URL = 'https://api.linear.app/graphql'
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// GraphQL client
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function resolveKey(config) {
|
|
176
|
+
const key = config?.apiKey || process.env.LINEAR_API_KEY
|
|
177
|
+
if (!key) throw new Error('Linear API key not configured: set the LINEAR_API_KEY environment variable (or pass `apiKey` in plugin config).')
|
|
178
|
+
return key
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function gql(key, query, variables) {
|
|
182
|
+
const res = await fetch(API_URL, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: {
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
// Linear API keys are sent as the raw Authorization value — NOT "Bearer <key>".
|
|
187
|
+
Authorization: key,
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify({ query, variables }),
|
|
190
|
+
})
|
|
191
|
+
const body = await res.json().catch(() => null)
|
|
192
|
+
if (!res.ok || body?.errors?.length) {
|
|
193
|
+
const msg = body?.errors?.map((e) => e.message).join('; ') || `HTTP ${res.status}`
|
|
194
|
+
throw new Error(`Linear API error: ${msg}`)
|
|
195
|
+
}
|
|
196
|
+
return body.data
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Field fragments
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const ISSUE_FIELDS = `
|
|
204
|
+
id identifier title description state { id name type }
|
|
205
|
+
assignee { id name }
|
|
206
|
+
priority
|
|
207
|
+
createdAt updatedAt
|
|
208
|
+
labels { nodes { id name } }
|
|
209
|
+
project { id name }
|
|
210
|
+
url
|
|
211
|
+
`
|
|
212
|
+
|
|
213
|
+
// Connection pagination block for list connections (issues, comments). Every
|
|
214
|
+
// direct list tool that supports `after` requests this alongside `nodes`.
|
|
215
|
+
const PAGE_INFO_FIELDS = `
|
|
216
|
+
pageInfo { hasNextPage endCursor }
|
|
217
|
+
`
|
|
218
|
+
|
|
219
|
+
const COMMENT_FIELDS = `
|
|
220
|
+
id body createdAt
|
|
221
|
+
`
|
|
222
|
+
|
|
223
|
+
const TEAM_FIELDS = `
|
|
224
|
+
id name key description
|
|
225
|
+
`
|
|
226
|
+
|
|
227
|
+
const STATE_FIELDS = `
|
|
228
|
+
id name type position
|
|
229
|
+
`
|
|
230
|
+
|
|
231
|
+
const LABEL_FIELDS = `
|
|
232
|
+
id name color description
|
|
233
|
+
`
|
|
234
|
+
|
|
235
|
+
const PROJECT_FIELDS = `
|
|
236
|
+
id name description state
|
|
237
|
+
teams { id name key }
|
|
238
|
+
`
|
|
239
|
+
|
|
240
|
+
const CYCLE_FIELDS = `
|
|
241
|
+
id number name startsAt endsAt completedAt
|
|
242
|
+
progress
|
|
243
|
+
team { id name key }
|
|
244
|
+
`
|
|
245
|
+
|
|
246
|
+
const USER_FIELDS = `
|
|
247
|
+
id name displayName email active
|
|
248
|
+
`
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Helpers
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
255
|
+
|
|
256
|
+
// Cursor pagination (direct GraphQL list tools).
|
|
257
|
+
//
|
|
258
|
+
// `after` is an optional opaque cursor: it is ALWAYS sent as a GraphQL variable
|
|
259
|
+
// (`$after: String`), never interpolated into the query text, so a cursor can
|
|
260
|
+
// never alter the document. When no cursor is supplied the declaration and the
|
|
261
|
+
// `after:` argument are both omitted, which keeps the wire request byte-identical
|
|
262
|
+
// to the pre-pagination form (backwards compatibility). Every direct list tool
|
|
263
|
+
// uses this shape: linear_issue_list, linear_comment_list, linear_team_list,
|
|
264
|
+
// linear_state_list, linear_label_list, linear_project_list, linear_cycle_list
|
|
265
|
+
// and linear_user_list.
|
|
266
|
+
// The result shape mirrors GraphQL's PageInfo — null when the server did not
|
|
267
|
+
// report one, so an older/blank response never invents pagination state.
|
|
268
|
+
function normalizePageInfo(pageInfo) {
|
|
269
|
+
if (!pageInfo || typeof pageInfo !== 'object') return null
|
|
270
|
+
return {
|
|
271
|
+
hasNextPage: pageInfo.hasNextPage === true,
|
|
272
|
+
endCursor: typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Entity resolution (resolve-then-mutate)
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
//
|
|
280
|
+
// Linear's GraphQL inputs are FLAT UUID fields — there is no nested
|
|
281
|
+
// `team`/`state` object on IssueCreateInput/IssueUpdateInput, and the singular
|
|
282
|
+
// `Query.team` field accepts ONLY a UUID `id` (no `key`/`name`/`filter`).
|
|
283
|
+
// So every human-facing identifier (team key, state name) must first be
|
|
284
|
+
// resolved to a UUID through the PLURAL `teams` / `workflowStates` query
|
|
285
|
+
// fields, which DO accept filters:
|
|
286
|
+
//
|
|
287
|
+
// teams(filter: { key: { eq: "APT" } }) { nodes { id key name } }
|
|
288
|
+
// workflowStates(filter: { team: { id: { eq: <teamId> } },
|
|
289
|
+
// name: { eq: "Backlog" } }) { nodes { id name } }
|
|
290
|
+
//
|
|
291
|
+
// The resolvers below throw a clear error when the identifier does not resolve
|
|
292
|
+
// to exactly one entity, so a bad key never reaches a mutation as a malformed
|
|
293
|
+
// nested object.
|
|
294
|
+
|
|
295
|
+
// Resolve a team key or name to its UUID via the PLURAL `teams` field (which
|
|
296
|
+
// accepts a TeamFilter). Pass a UUID through unchanged.
|
|
297
|
+
async function resolveTeamId(key, ref) {
|
|
298
|
+
if (UUID_RE.test(ref)) return ref
|
|
299
|
+
const isEmail = ref.includes('@')
|
|
300
|
+
const data = await gql(key, `
|
|
301
|
+
query($v: String!) {
|
|
302
|
+
teams(first: 10, filter: { ${isEmail ? 'name' : 'key'}: { eq: $v } }) {
|
|
303
|
+
nodes { id key name }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
`, { v: ref })
|
|
307
|
+
const nodes = data.teams?.nodes ?? []
|
|
308
|
+
if (nodes.length !== 1) {
|
|
309
|
+
throw new Error(`Team '${ref}' not found (resolved ${nodes.length} teams via the teams filter)`)
|
|
310
|
+
}
|
|
311
|
+
return nodes[0].id
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Resolve a workflow-state name to its UUID within a team (UUID required).
|
|
315
|
+
// Uses the top-level `workflowStates` query field, filtered by team id + name.
|
|
316
|
+
async function resolveStateId(key, teamId, stateName) {
|
|
317
|
+
const data = await gql(key, `
|
|
318
|
+
query($teamId: String!, $name: String!) {
|
|
319
|
+
workflowStates(filter: { team: { id: { eq: $teamId } }, name: { eq: $name } }) {
|
|
320
|
+
nodes { id name }
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
`, { teamId, name: stateName })
|
|
324
|
+
const nodes = data.workflowStates?.nodes ?? []
|
|
325
|
+
if (nodes.length !== 1) {
|
|
326
|
+
throw new Error(`State '${stateName}' not found for team ${teamId} (resolved ${nodes.length} states)`)
|
|
327
|
+
}
|
|
328
|
+
return nodes[0].id
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Resolve a project name or a UUID to a Linear project UUID. The singular
|
|
332
|
+
// `project` field accepts ONLY a UUID id, so a name goes through the PLURAL
|
|
333
|
+
// `projects` field (which takes a ProjectFilter). Unlike teams, a non-UUID that
|
|
334
|
+
// simply matches nothing returns null here rather than throwing, so the caller
|
|
335
|
+
// renders the same `Project not found: <x>` shape the read tools use.
|
|
336
|
+
// More than one node is NOT silently shortened to the first one: an update aimed
|
|
337
|
+
// at an ambiguous name could modify the wrong project, so it throws and
|
|
338
|
+
// resolveLinearProjectId turns that into a clean { error } before any mutation.
|
|
339
|
+
async function findProjectId(key, ref) {
|
|
340
|
+
if (UUID_RE.test(ref)) return ref
|
|
341
|
+
const data = await gql(key, `
|
|
342
|
+
query($name: String!) {
|
|
343
|
+
projects(filter: { name: { eq: $name } }) {
|
|
344
|
+
nodes { id }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
`, { name: ref })
|
|
348
|
+
const nodes = data.projects?.nodes ?? []
|
|
349
|
+
if (nodes.length > 1) {
|
|
350
|
+
throw new Error(`Project name '${ref}' is ambiguous (resolved ${nodes.length} projects via the projects filter); use the project id (UUID) instead`)
|
|
351
|
+
}
|
|
352
|
+
return nodes[0]?.id ?? null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// findProjectId with its two failure shapes rendered for a tool: `null` means
|
|
356
|
+
// genuinely not found, and an ambiguity throw becomes its own clean error string.
|
|
357
|
+
async function resolveLinearProjectId(key, ref) {
|
|
358
|
+
try {
|
|
359
|
+
const projectId = await findProjectId(key, ref)
|
|
360
|
+
return { projectId }
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (/is ambiguous/.test(err?.message ?? '')) {
|
|
363
|
+
return { error: `Project name '${ref}' matched multiple projects — refine the name or use the project id (UUID)` }
|
|
364
|
+
}
|
|
365
|
+
throw err
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Resolve a human identifier ("APT-804") or a UUID to a Linear issue UUID.
|
|
370
|
+
// IssueFilter has no `identifier` field, so we split the identifier into
|
|
371
|
+
// team key + number and look the issue up by those, then return its id.
|
|
372
|
+
// `includeArchived` opts into IssuesQuery.includeArchived: an ARCHIVED issue is
|
|
373
|
+
// invisible to the plain issues query, so a tool that must reach one (only
|
|
374
|
+
// linear_issue_unarchive) passes it. Every other caller omits the option, which
|
|
375
|
+
// keeps the document byte-for-byte what it was before — no extra argument and
|
|
376
|
+
// no extra variable are sent when includeArchived is falsy.
|
|
377
|
+
async function findIssueId(key, id, { includeArchived = false } = {}) {
|
|
378
|
+
if (UUID_RE.test(id)) return id
|
|
379
|
+
const m = id.match(/^(.+?)-(\d+)$/)
|
|
380
|
+
if (!m) return null
|
|
381
|
+
const [, teamKey, num] = m
|
|
382
|
+
const data = await gql(key, `
|
|
383
|
+
query($team: String!, $num: Float!) {
|
|
384
|
+
issues(filter: { team: { key: { eq: $team } }, number: { eq: $num } }${includeArchived ? ', includeArchived: true' : ''}) {
|
|
385
|
+
nodes { id }
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
`, { team: teamKey, num: Number(num) })
|
|
389
|
+
return data.issues.nodes[0]?.id ?? null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Shared body for linear_issue_archive / linear_issue_unarchive. Both mutations
|
|
393
|
+
// take a bare `id` argument (there is no IssueArchiveInput type) and both return
|
|
394
|
+
// IssueArchivePayload { success entity lastSyncId } — the archived issue comes
|
|
395
|
+
// back under `entity`, NOT `issue` (verified by introspection). The identifier is
|
|
396
|
+
// resolved to a UUID first, so an unknown "APT-999" fails before any mutation.
|
|
397
|
+
// Unarchiving resolves the identifier with includeArchived: true, because an
|
|
398
|
+
// archived issue is exactly what the plain issues query hides — without it the
|
|
399
|
+
// tool could never unarchive anything it identified by name. Archiving keeps the
|
|
400
|
+
// ordinary lookup (an archived issue needs no second archive).
|
|
401
|
+
async function archiveIssue(key, id, unarchive) {
|
|
402
|
+
const issueId = await findIssueId(key, id, { includeArchived: unarchive })
|
|
403
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${id}` })
|
|
404
|
+
const mutation = unarchive ? 'issueUnarchive' : 'issueArchive'
|
|
405
|
+
const data = await gql(key, `
|
|
406
|
+
mutation($id: String!) {
|
|
407
|
+
${mutation}(id: $id) {
|
|
408
|
+
success entity { ${ISSUE_FIELDS} }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
`, { id: issueId })
|
|
412
|
+
const payload = data[mutation]
|
|
413
|
+
if (!payload?.success) return JSON.stringify({ error: `${mutation} failed` })
|
|
414
|
+
const i = payload.entity
|
|
415
|
+
if (!i) return JSON.stringify({ ok: true, archived: !unarchive })
|
|
416
|
+
return JSON.stringify({
|
|
417
|
+
ok: true,
|
|
418
|
+
archived: !unarchive,
|
|
419
|
+
id: i.id,
|
|
420
|
+
identifier: i.identifier,
|
|
421
|
+
title: i.title,
|
|
422
|
+
state: i.state?.name,
|
|
423
|
+
url: i.url,
|
|
424
|
+
}, null, 2)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// GraphQL tools
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
function registerLinearTools(ctx, config) {
|
|
432
|
+
const key = () => resolveKey(config)
|
|
433
|
+
|
|
434
|
+
// Wrap only Linear tools so any failure (a resolution error like
|
|
435
|
+
// "Team 'X' not found", a Linear API error, or a network error) surfaces as
|
|
436
|
+
// a clean JSON { error } payload instead of a thrown stack trace. The
|
|
437
|
+
// resolution helpers throw deliberately; this guard turns that into the
|
|
438
|
+
// clear, model-readable error the bug report requires.
|
|
439
|
+
const originalRegister = ctx.tools.register.bind(ctx.tools)
|
|
440
|
+
const register = (def) =>
|
|
441
|
+
originalRegister({
|
|
442
|
+
...def,
|
|
443
|
+
execute: async (args, opts) => {
|
|
444
|
+
try {
|
|
445
|
+
return await def.execute(args, opts)
|
|
446
|
+
} catch (err) {
|
|
447
|
+
return JSON.stringify({ error: err?.message || String(err) })
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
// -- Issues ---------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
register(
|
|
455
|
+
defineTool({
|
|
456
|
+
name: 'linear_issue_list',
|
|
457
|
+
description:
|
|
458
|
+
'List Linear issues. Optionally filter by team key (e.g. "APT"), state name, or free-text search. Returns up to `limit` most recent issues (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
459
|
+
parameters: {
|
|
460
|
+
team: { type: 'string', description: 'Team key to filter by, e.g. "APT"' },
|
|
461
|
+
state: { type: 'string', description: 'State name to filter by, e.g. "Done", "In Progress"' },
|
|
462
|
+
search: { type: 'string', description: 'Free-text search across title/description' },
|
|
463
|
+
limit: { type: 'number', description: 'Max issues to return (default 20, max 50)' },
|
|
464
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
465
|
+
},
|
|
466
|
+
output: {
|
|
467
|
+
schema: { type: 'string' },
|
|
468
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
469
|
+
},
|
|
470
|
+
async execute(args) {
|
|
471
|
+
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
472
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
473
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
474
|
+
const filterParts = []
|
|
475
|
+
const vars = {}
|
|
476
|
+
if (args.team) { filterParts.push(`team: { key: { eq: $team } }`); vars.team = args.team }
|
|
477
|
+
if (args.state) { filterParts.push(`state: { name: { eq: $state } }`); vars.state = args.state }
|
|
478
|
+
if (args.search) { filterParts.push(`OR: { title: { containsInsensitive: $search }, description: { containsInsensitive: $search } }`); vars.search = args.search }
|
|
479
|
+
const filter = filterParts.length ? `filter: { ${filterParts.join(', ')} }` : ''
|
|
480
|
+
if (after !== undefined) vars.after = after
|
|
481
|
+
const usedVars = Object.keys(vars)
|
|
482
|
+
const varDecl = usedVars.length ? `query(${usedVars.map((v) => `$${v}: String`).join(', ')})` : 'query'
|
|
483
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
484
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
485
|
+
const argList = [filter, after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
486
|
+
const data = await gql(key(), `
|
|
487
|
+
${varDecl} {
|
|
488
|
+
issues(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
489
|
+
nodes { ${ISSUE_FIELDS} }
|
|
490
|
+
${PAGE_INFO_FIELDS}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
`, vars)
|
|
494
|
+
const issues = data.issues.nodes.map((i) => ({
|
|
495
|
+
id: i.id,
|
|
496
|
+
identifier: i.identifier,
|
|
497
|
+
title: i.title,
|
|
498
|
+
state: i.state?.name,
|
|
499
|
+
assignee: i.assignee?.name,
|
|
500
|
+
priority: i.priority,
|
|
501
|
+
project: i.project?.name,
|
|
502
|
+
labels: i.labels.nodes.map((l) => l.name),
|
|
503
|
+
url: i.url,
|
|
504
|
+
}))
|
|
505
|
+
return JSON.stringify({ count: issues.length, issues, pageInfo: normalizePageInfo(data.issues.pageInfo) }, null, 2)
|
|
506
|
+
},
|
|
507
|
+
}),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
register(
|
|
511
|
+
defineTool({
|
|
512
|
+
name: 'linear_issue_get',
|
|
513
|
+
description:
|
|
514
|
+
'Get a single Linear issue by its id (UUID) or identifier (e.g. "APT-123"). Returns full details including description, state, assignee, labels, project, and relations.',
|
|
515
|
+
parameters: {
|
|
516
|
+
id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier like "APT-123"' },
|
|
517
|
+
},
|
|
518
|
+
output: {
|
|
519
|
+
schema: { type: 'string' },
|
|
520
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
521
|
+
},
|
|
522
|
+
async execute(args) {
|
|
523
|
+
const issueId = await findIssueId(key(), args.id)
|
|
524
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.id}` })
|
|
525
|
+
const data = await gql(key(), `
|
|
526
|
+
query($id: String!) {
|
|
527
|
+
issue: issue(id: $id) {
|
|
528
|
+
${ISSUE_FIELDS}
|
|
529
|
+
relations { nodes { type relatedIssue { ${ISSUE_FIELDS} } } }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
`, { id: issueId })
|
|
533
|
+
const i = data.issue
|
|
534
|
+
if (!i) return JSON.stringify({ error: `Issue not found: ${args.id}` })
|
|
535
|
+
return JSON.stringify({
|
|
536
|
+
id: i.id,
|
|
537
|
+
identifier: i.identifier,
|
|
538
|
+
title: i.title,
|
|
539
|
+
description: i.description,
|
|
540
|
+
state: i.state?.name,
|
|
541
|
+
assignee: i.assignee?.name,
|
|
542
|
+
priority: i.priority,
|
|
543
|
+
project: i.project?.name,
|
|
544
|
+
labels: i.labels.nodes.map((l) => l.name),
|
|
545
|
+
relations: (i.relations?.nodes ?? i.relations ?? []).map((r) => r.relatedIssue && { id: r.relatedIssue.id, identifier: r.relatedIssue.identifier, title: r.relatedIssue.title, type: r.type }),
|
|
546
|
+
url: i.url,
|
|
547
|
+
createdAt: i.createdAt,
|
|
548
|
+
updatedAt: i.updatedAt,
|
|
549
|
+
}, null, 2)
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
register(
|
|
555
|
+
defineTool({
|
|
556
|
+
name: 'linear_issue_create',
|
|
557
|
+
description:
|
|
558
|
+
'Create a new Linear issue in a team. `team` is the team key (e.g. "APT"). Optional: title (required), description, state name, assignee id, label ids, priority (0-4).',
|
|
559
|
+
parameters: {
|
|
560
|
+
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
561
|
+
title: { type: 'string', required: true, description: 'Issue title' },
|
|
562
|
+
description: { type: 'string', description: 'Issue description (markdown)' },
|
|
563
|
+
state: { type: 'string', description: 'Initial state name, e.g. "Todo", "In Progress"' },
|
|
564
|
+
assignee: { type: 'string', description: 'Assignee user id (UUID)' },
|
|
565
|
+
labels: { type: 'array', items: { type: 'string' }, description: 'Label ids (UUIDs) to attach' },
|
|
566
|
+
priority: { type: 'number', description: 'Priority 0 (none) to 4 (urgent)' },
|
|
567
|
+
},
|
|
568
|
+
output: {
|
|
569
|
+
schema: { type: 'string' },
|
|
570
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
571
|
+
},
|
|
572
|
+
async execute(args) {
|
|
573
|
+
// Resolve human identifiers to UUIDs first (IssueCreateInput is flat:
|
|
574
|
+
// teamId is required, stateId/assigneeId/labelIds are UUIDs).
|
|
575
|
+
const teamId = await resolveTeamId(key(), args.team)
|
|
576
|
+
const input = { teamId, title: args.title }
|
|
577
|
+
if (args.description) input.description = args.description
|
|
578
|
+
if (args.state) input.stateId = await resolveStateId(key(), teamId, args.state)
|
|
579
|
+
if (args.assignee) input.assigneeId = args.assignee
|
|
580
|
+
if (args.labels?.length) input.labelIds = args.labels
|
|
581
|
+
if (args.priority !== undefined) input.priority = args.priority
|
|
582
|
+
const data = await gql(key(), `
|
|
583
|
+
mutation($input: IssueCreateInput!) {
|
|
584
|
+
issueCreate(input: $input) {
|
|
585
|
+
success issue { ${ISSUE_FIELDS} }
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
`, { input })
|
|
589
|
+
if (!data.issueCreate.success) return JSON.stringify({ error: 'issueCreate failed' })
|
|
590
|
+
const i = data.issueCreate.issue
|
|
591
|
+
return JSON.stringify({
|
|
592
|
+
id: i.id,
|
|
593
|
+
identifier: i.identifier,
|
|
594
|
+
title: i.title,
|
|
595
|
+
state: i.state?.name,
|
|
596
|
+
url: i.url,
|
|
597
|
+
}, null, 2)
|
|
598
|
+
},
|
|
599
|
+
}),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
register(
|
|
603
|
+
defineTool({
|
|
604
|
+
name: 'linear_issue_update',
|
|
605
|
+
description:
|
|
606
|
+
'Update an existing Linear issue by id (UUID) or identifier. Any of: state name, description, assignee id, label ids, priority (0-4), title.',
|
|
607
|
+
parameters: {
|
|
608
|
+
id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
|
|
609
|
+
title: { type: 'string', description: 'New title' },
|
|
610
|
+
description: { type: 'string', description: 'New description (markdown)' },
|
|
611
|
+
state: { type: 'string', description: 'New state name, e.g. "Done", "In Progress"' },
|
|
612
|
+
assignee: { type: 'string', description: 'New assignee user id (UUID); empty string to unassign' },
|
|
613
|
+
labels: { type: 'array', items: { type: 'string' }, description: 'Label ids to set (replaces existing)' },
|
|
614
|
+
priority: { type: 'number', description: 'Priority 0-4' },
|
|
615
|
+
},
|
|
616
|
+
output: {
|
|
617
|
+
schema: { type: 'string' },
|
|
618
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
619
|
+
},
|
|
620
|
+
async execute(args) {
|
|
621
|
+
const issueId = await findIssueId(key(), args.id)
|
|
622
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.id}` })
|
|
623
|
+
|
|
624
|
+
// IssueUpdateInput is flat (stateId/assigneeId/labelIds are UUIDs).
|
|
625
|
+
// A state change needs the issue's team to resolve the state name.
|
|
626
|
+
const input = { id: issueId }
|
|
627
|
+
if (args.title !== undefined) input.title = args.title
|
|
628
|
+
if (args.description !== undefined) input.description = args.description
|
|
629
|
+
if (args.assignee !== undefined) input.assigneeId = args.assignee || null
|
|
630
|
+
if (args.labels !== undefined) input.labelIds = args.labels
|
|
631
|
+
if (args.priority !== undefined) input.priority = args.priority
|
|
632
|
+
if (args.state !== undefined) {
|
|
633
|
+
const issueData = await gql(key(), `
|
|
634
|
+
query($id: String!) {
|
|
635
|
+
issue: issue(id: $id) { id team { id } }
|
|
636
|
+
}
|
|
637
|
+
`, { id: issueId })
|
|
638
|
+
const teamId = issueData.issue?.team?.id
|
|
639
|
+
if (!teamId) return JSON.stringify({ error: `Could not resolve team for issue ${args.id}` })
|
|
640
|
+
input.stateId = await resolveStateId(key(), teamId, args.state)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const data = await gql(key(), `
|
|
644
|
+
mutation($input: IssueUpdateInput!) {
|
|
645
|
+
issueUpdate(input: $input) {
|
|
646
|
+
success issue { ${ISSUE_FIELDS} }
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
`, { input })
|
|
650
|
+
if (!data.issueUpdate.success) return JSON.stringify({ error: 'issueUpdate failed' })
|
|
651
|
+
const i = data.issueUpdate.issue
|
|
652
|
+
return JSON.stringify({
|
|
653
|
+
id: i.id,
|
|
654
|
+
identifier: i.identifier,
|
|
655
|
+
title: i.title,
|
|
656
|
+
state: i.state?.name,
|
|
657
|
+
assignee: i.assignee?.name,
|
|
658
|
+
url: i.url,
|
|
659
|
+
}, null, 2)
|
|
660
|
+
},
|
|
661
|
+
}),
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
register(
|
|
665
|
+
defineTool({
|
|
666
|
+
name: 'linear_issue_archive',
|
|
667
|
+
description:
|
|
668
|
+
'Archive a Linear issue by id (UUID) or identifier. Archived issues leave the team\'s default views. To bring an archived issue back, use the separate linear_issue_unarchive tool — this tool takes no unarchive argument.',
|
|
669
|
+
parameters: {
|
|
670
|
+
id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
|
|
671
|
+
},
|
|
672
|
+
output: {
|
|
673
|
+
schema: { type: 'string' },
|
|
674
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
675
|
+
},
|
|
676
|
+
async execute(args) {
|
|
677
|
+
return archiveIssue(key(), args.id, false)
|
|
678
|
+
},
|
|
679
|
+
}),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
register(
|
|
683
|
+
defineTool({
|
|
684
|
+
name: 'linear_issue_unarchive',
|
|
685
|
+
description: 'Unarchive a previously archived Linear issue by id (UUID) or identifier, returning it to the team\'s views.',
|
|
686
|
+
parameters: {
|
|
687
|
+
id: { type: 'string', required: true, description: 'Issue id (UUID) or identifier' },
|
|
688
|
+
},
|
|
689
|
+
output: {
|
|
690
|
+
schema: { type: 'string' },
|
|
691
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
692
|
+
},
|
|
693
|
+
async execute(args) {
|
|
694
|
+
return archiveIssue(key(), args.id, true)
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
// -- Comments ---------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
register(
|
|
702
|
+
defineTool({
|
|
703
|
+
name: 'linear_comment_add',
|
|
704
|
+
description: 'Add a comment to a Linear issue (by id or identifier).',
|
|
705
|
+
parameters: {
|
|
706
|
+
issue: { type: 'string', required: true, description: 'Issue id or identifier' },
|
|
707
|
+
body: { type: 'string', required: true, description: 'Comment body (markdown)' },
|
|
708
|
+
},
|
|
709
|
+
output: {
|
|
710
|
+
schema: { type: 'string' },
|
|
711
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
712
|
+
},
|
|
713
|
+
async execute(args) {
|
|
714
|
+
const issueId = await findIssueId(key(), args.issue)
|
|
715
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
|
|
716
|
+
const data = await gql(key(), `
|
|
717
|
+
mutation($input: CommentCreateInput!) {
|
|
718
|
+
commentCreate(input: $input) {
|
|
719
|
+
success comment { ${COMMENT_FIELDS} }
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
`, { input: { issueId, body: args.body } })
|
|
723
|
+
if (!data.commentCreate.success) return JSON.stringify({ error: 'commentCreate failed' })
|
|
724
|
+
return JSON.stringify({ id: data.commentCreate.comment.id, body: data.commentCreate.comment.body }, null, 2)
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
register(
|
|
730
|
+
defineTool({
|
|
731
|
+
name: 'linear_comment_list',
|
|
732
|
+
description: 'List comments on a Linear issue (by id or identifier), newest first. Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
733
|
+
parameters: {
|
|
734
|
+
issue: { type: 'string', required: true, description: 'Issue id or identifier' },
|
|
735
|
+
limit: { type: 'number', description: 'Max comments (default 20, max 50)' },
|
|
736
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
737
|
+
},
|
|
738
|
+
output: {
|
|
739
|
+
schema: { type: 'string' },
|
|
740
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
741
|
+
},
|
|
742
|
+
async execute(args) {
|
|
743
|
+
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
744
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
745
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
746
|
+
const issueId = await findIssueId(key(), args.issue)
|
|
747
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
|
|
748
|
+
const vars = { id: issueId }
|
|
749
|
+
if (after !== undefined) vars.after = after
|
|
750
|
+
const varDecl = after !== undefined ? 'query($id: String!, $after: String)' : 'query($id: String!)'
|
|
751
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
752
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
753
|
+
const argList = after !== undefined ? 'after: $after' : ''
|
|
754
|
+
const data = await gql(key(), `
|
|
755
|
+
${varDecl} {
|
|
756
|
+
issue: issue(id: $id) {
|
|
757
|
+
id
|
|
758
|
+
comments(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
759
|
+
nodes { ${COMMENT_FIELDS} }
|
|
760
|
+
${PAGE_INFO_FIELDS}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
`, vars)
|
|
765
|
+
if (!data.issue) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
|
|
766
|
+
return JSON.stringify({
|
|
767
|
+
issue: args.issue,
|
|
768
|
+
count: data.issue.comments.nodes.length,
|
|
769
|
+
comments: data.issue.comments.nodes,
|
|
770
|
+
pageInfo: normalizePageInfo(data.issue.comments.pageInfo),
|
|
771
|
+
}, null, 2)
|
|
772
|
+
},
|
|
773
|
+
}),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
register(
|
|
777
|
+
defineTool({
|
|
778
|
+
name: 'linear_comment_update',
|
|
779
|
+
description: 'Update the body of an existing comment on a Linear issue. `id` is the comment UUID (from linear_comment_list); `body` is the new markdown body.',
|
|
780
|
+
parameters: {
|
|
781
|
+
id: { type: 'string', required: true, description: 'Comment id (UUID)' },
|
|
782
|
+
body: { type: 'string', required: true, description: 'New comment body (markdown)' },
|
|
783
|
+
},
|
|
784
|
+
output: {
|
|
785
|
+
schema: { type: 'string' },
|
|
786
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
787
|
+
},
|
|
788
|
+
async execute(args) {
|
|
789
|
+
// CommentUpdateInput carries only the changed fields; the comment UUID is
|
|
790
|
+
// a top-level `id` argument on the mutation (verified by introspection).
|
|
791
|
+
const data = await gql(key(), `
|
|
792
|
+
mutation($id: String!, $input: CommentUpdateInput!) {
|
|
793
|
+
commentUpdate(id: $id, input: $input) {
|
|
794
|
+
success comment { ${COMMENT_FIELDS} }
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
`, { id: args.id, input: { body: args.body } })
|
|
798
|
+
if (!data.commentUpdate.success) return JSON.stringify({ error: 'commentUpdate failed' })
|
|
799
|
+
const c = data.commentUpdate.comment
|
|
800
|
+
return JSON.stringify({ id: c?.id, body: c?.body }, null, 2)
|
|
801
|
+
},
|
|
802
|
+
}),
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
register(
|
|
806
|
+
defineTool({
|
|
807
|
+
name: 'linear_comment_delete',
|
|
808
|
+
description: 'Delete a comment from a Linear issue by its comment id (UUID, from linear_comment_list). This is destructive and cannot be undone.',
|
|
809
|
+
parameters: {
|
|
810
|
+
id: { type: 'string', required: true, description: 'Comment id (UUID)' },
|
|
811
|
+
},
|
|
812
|
+
output: {
|
|
813
|
+
schema: { type: 'string' },
|
|
814
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
815
|
+
},
|
|
816
|
+
async execute(args) {
|
|
817
|
+
// commentDelete takes a bare `id` argument — there is NO CommentDeleteInput
|
|
818
|
+
// type in the Linear schema (verified by introspection). Its payload is
|
|
819
|
+
// DeletePayload { success entityId lastSyncId } — no entity object.
|
|
820
|
+
const data = await gql(key(), `
|
|
821
|
+
mutation($id: String!) {
|
|
822
|
+
commentDelete(id: $id) {
|
|
823
|
+
success entityId
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
`, { id: args.id })
|
|
827
|
+
if (!data.commentDelete.success) return JSON.stringify({ error: 'commentDelete failed' })
|
|
828
|
+
return JSON.stringify({ ok: true, deletedId: data.commentDelete.entityId ?? args.id }, null, 2)
|
|
829
|
+
},
|
|
830
|
+
}),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
// -- Relations --------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
register(
|
|
836
|
+
defineTool({
|
|
837
|
+
name: 'linear_relation_add',
|
|
838
|
+
description:
|
|
839
|
+
'Link a related issue to an issue. Both given by id or identifier. `type` is the relation type (default "related").',
|
|
840
|
+
parameters: {
|
|
841
|
+
issue: { type: 'string', required: true, description: 'Source issue id or identifier' },
|
|
842
|
+
related: { type: 'string', required: true, description: 'Related issue id or identifier' },
|
|
843
|
+
type: { type: 'string', description: 'Relation type: related, blockedBy, blocks, parent, child (default "related")' },
|
|
844
|
+
},
|
|
845
|
+
output: {
|
|
846
|
+
schema: { type: 'string' },
|
|
847
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
848
|
+
},
|
|
849
|
+
async execute(args) {
|
|
850
|
+
const type = args.type ?? 'related'
|
|
851
|
+
const VALID_RELATION_TYPES = ['related', 'blockedBy', 'blocks', 'parent', 'child']
|
|
852
|
+
if (!VALID_RELATION_TYPES.includes(type)) {
|
|
853
|
+
return JSON.stringify({ error: `invalid relation type: ${type}, expected one of: ${VALID_RELATION_TYPES.join(', ')}` })
|
|
854
|
+
}
|
|
855
|
+
const [issueId, relatedId] = await Promise.all([
|
|
856
|
+
findIssueId(key(), args.issue),
|
|
857
|
+
findIssueId(key(), args.related),
|
|
858
|
+
])
|
|
859
|
+
if (!issueId) return JSON.stringify({ error: `Issue not found: ${args.issue}` })
|
|
860
|
+
if (!relatedId) return JSON.stringify({ error: `Issue not found: ${args.related}` })
|
|
861
|
+
const data = await gql(key(), `
|
|
862
|
+
mutation($input: IssueRelationCreateInput!) {
|
|
863
|
+
issueRelationCreate(input: $input) {
|
|
864
|
+
success relation { id }
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
`, { input: { issueId, relatedIssueId: relatedId, type } })
|
|
868
|
+
if (!data.issueRelationCreate.success) return JSON.stringify({ error: 'issueRelationCreate failed' })
|
|
869
|
+
return JSON.stringify({ ok: true, relationId: data.issueRelationCreate.relation.id }, null, 2)
|
|
870
|
+
},
|
|
871
|
+
}),
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
// -- Teams / states / labels / projects -------------------------------------
|
|
875
|
+
|
|
876
|
+
register(
|
|
877
|
+
defineTool({
|
|
878
|
+
name: 'linear_team_list',
|
|
879
|
+
description: 'List all Linear teams (key, name, description). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
880
|
+
parameters: {
|
|
881
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
882
|
+
},
|
|
883
|
+
output: {
|
|
884
|
+
schema: { type: 'string' },
|
|
885
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
886
|
+
},
|
|
887
|
+
async execute(args) {
|
|
888
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
889
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
890
|
+
const vars = {}
|
|
891
|
+
if (after !== undefined) vars.after = after
|
|
892
|
+
const varDecl = after !== undefined ? 'query($after: String)' : 'query'
|
|
893
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
894
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
895
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
896
|
+
const data = await gql(key(), `
|
|
897
|
+
${varDecl} {
|
|
898
|
+
teams(first: 100${argList ? `, ${argList}` : ''}) {
|
|
899
|
+
nodes { ${TEAM_FIELDS} }
|
|
900
|
+
${PAGE_INFO_FIELDS}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
`, vars)
|
|
904
|
+
return JSON.stringify({ count: data.teams.nodes.length, teams: data.teams.nodes, pageInfo: normalizePageInfo(data.teams.pageInfo) }, null, 2)
|
|
905
|
+
},
|
|
906
|
+
}),
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
register(
|
|
910
|
+
defineTool({
|
|
911
|
+
name: 'linear_state_list',
|
|
912
|
+
description: 'List workflow states for a team (e.g. Todo, In Progress, Done). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
913
|
+
parameters: {
|
|
914
|
+
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
915
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
916
|
+
},
|
|
917
|
+
output: {
|
|
918
|
+
schema: { type: 'string' },
|
|
919
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
920
|
+
},
|
|
921
|
+
async execute(args) {
|
|
922
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
923
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
924
|
+
// Resolve the team to a UUID via the PLURAL `teams` field (the singular
|
|
925
|
+
// `team` field takes only a UUID id), then list that team's states via
|
|
926
|
+
// the top-level `workflowStates` field filtered by team id.
|
|
927
|
+
const teamId = await resolveTeamId(key(), args.team)
|
|
928
|
+
const vars = { teamId }
|
|
929
|
+
if (after !== undefined) vars.after = after
|
|
930
|
+
const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
|
|
931
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
932
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
933
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
934
|
+
const data = await gql(key(), `
|
|
935
|
+
${varDecl} {
|
|
936
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
|
|
937
|
+
nodes { ${STATE_FIELDS} }
|
|
938
|
+
${PAGE_INFO_FIELDS}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
`, vars)
|
|
942
|
+
return JSON.stringify({ team: args.team, states: data.workflowStates.nodes, pageInfo: normalizePageInfo(data.workflowStates.pageInfo) }, null, 2)
|
|
943
|
+
},
|
|
944
|
+
}),
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
register(
|
|
948
|
+
defineTool({
|
|
949
|
+
name: 'linear_label_list',
|
|
950
|
+
description: 'List labels for a team. Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
951
|
+
parameters: {
|
|
952
|
+
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
953
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
954
|
+
},
|
|
955
|
+
output: {
|
|
956
|
+
schema: { type: 'string' },
|
|
957
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
958
|
+
},
|
|
959
|
+
async execute(args) {
|
|
960
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
961
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
962
|
+
const teamId = await resolveTeamId(key(), args.team)
|
|
963
|
+
const vars = { teamId }
|
|
964
|
+
if (after !== undefined) vars.after = after
|
|
965
|
+
const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
|
|
966
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
967
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
968
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
969
|
+
const data = await gql(key(), `
|
|
970
|
+
${varDecl} {
|
|
971
|
+
issueLabels(filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
|
|
972
|
+
nodes { ${LABEL_FIELDS} }
|
|
973
|
+
${PAGE_INFO_FIELDS}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
`, vars)
|
|
977
|
+
return JSON.stringify({ team: args.team, labels: data.issueLabels.nodes, pageInfo: normalizePageInfo(data.issueLabels.pageInfo) }, null, 2)
|
|
978
|
+
},
|
|
979
|
+
}),
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
register(
|
|
983
|
+
defineTool({
|
|
984
|
+
name: 'linear_project_list',
|
|
985
|
+
description: 'List Linear projects (name, description, state, teams). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
986
|
+
parameters: {
|
|
987
|
+
limit: { type: 'number', description: 'Max projects (default 20, max 50)' },
|
|
988
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
989
|
+
},
|
|
990
|
+
output: {
|
|
991
|
+
schema: { type: 'string' },
|
|
992
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
993
|
+
},
|
|
994
|
+
async execute(args) {
|
|
995
|
+
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
996
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
997
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
998
|
+
const vars = {}
|
|
999
|
+
if (after !== undefined) vars.after = after
|
|
1000
|
+
const varDecl = after !== undefined ? 'query($after: String)' : 'query'
|
|
1001
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
1002
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
1003
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
1004
|
+
const data = await gql(key(), `
|
|
1005
|
+
${varDecl} {
|
|
1006
|
+
projects(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
1007
|
+
nodes { ${PROJECT_FIELDS} }
|
|
1008
|
+
${PAGE_INFO_FIELDS}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
`, vars)
|
|
1012
|
+
return JSON.stringify({ count: data.projects.nodes.length, projects: data.projects.nodes, pageInfo: normalizePageInfo(data.projects.pageInfo) }, null, 2)
|
|
1013
|
+
},
|
|
1014
|
+
}),
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
register(
|
|
1018
|
+
defineTool({
|
|
1019
|
+
name: 'linear_project_create',
|
|
1020
|
+
description: 'Create a Linear project. `name` required; optional description and team keys to include.',
|
|
1021
|
+
parameters: {
|
|
1022
|
+
name: { type: 'string', required: true, description: 'Project name' },
|
|
1023
|
+
description: { type: 'string', description: 'Project description' },
|
|
1024
|
+
teams: { type: 'array', items: { type: 'string' }, description: 'Team keys to include in the project' },
|
|
1025
|
+
},
|
|
1026
|
+
output: {
|
|
1027
|
+
schema: { type: 'string' },
|
|
1028
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1029
|
+
},
|
|
1030
|
+
async execute(args) {
|
|
1031
|
+
const input = { name: args.name }
|
|
1032
|
+
if (args.description) input.description = args.description
|
|
1033
|
+
if (args.teams?.length) {
|
|
1034
|
+
// ProjectCreateInput takes flat team UUIDs (teamIds), not nested keys.
|
|
1035
|
+
input.teamIds = await Promise.all(args.teams.map((t) => resolveTeamId(key(), t)))
|
|
1036
|
+
}
|
|
1037
|
+
const data = await gql(key(), `
|
|
1038
|
+
mutation($input: ProjectCreateInput!) {
|
|
1039
|
+
projectCreate(input: $input) {
|
|
1040
|
+
success project { ${PROJECT_FIELDS} }
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
`, { input })
|
|
1044
|
+
if (!data.projectCreate.success) return JSON.stringify({ error: 'projectCreate failed' })
|
|
1045
|
+
return JSON.stringify(data.projectCreate.project, null, 2)
|
|
1046
|
+
},
|
|
1047
|
+
}),
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
register(
|
|
1051
|
+
defineTool({
|
|
1052
|
+
name: 'linear_project_update',
|
|
1053
|
+
description:
|
|
1054
|
+
'Update a Linear project by id (UUID) or exact name. Any of: name, description, content, icon, color, team keys, start date, target date, priority.',
|
|
1055
|
+
parameters: {
|
|
1056
|
+
id: { type: 'string', required: true, description: 'Project id (UUID) or exact project name' },
|
|
1057
|
+
name: { type: 'string', description: 'New project name' },
|
|
1058
|
+
description: { type: 'string', description: 'New project description' },
|
|
1059
|
+
content: { type: 'string', description: 'New project content (markdown)' },
|
|
1060
|
+
icon: { type: 'string', description: 'Project icon identifier, e.g. "Rocket"' },
|
|
1061
|
+
color: { type: 'string', description: 'Hex color, e.g. "#ff0000"' },
|
|
1062
|
+
teams: { type: 'array', items: { type: 'string' }, description: 'Team keys to set (replaces existing)' },
|
|
1063
|
+
startDate: { type: 'string', description: 'Start date, YYYY-MM-DD' },
|
|
1064
|
+
targetDate: { type: 'string', description: 'Target date, YYYY-MM-DD' },
|
|
1065
|
+
priority: { type: 'number', description: 'Priority 0 (none) to 4 (urgent)' },
|
|
1066
|
+
},
|
|
1067
|
+
output: {
|
|
1068
|
+
schema: { type: 'string' },
|
|
1069
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1070
|
+
},
|
|
1071
|
+
async execute(args) {
|
|
1072
|
+
// A name that matches more than one project never reaches the mutation —
|
|
1073
|
+
// resolveLinearProjectId answers with a clean ambiguity error instead.
|
|
1074
|
+
const resolved = await resolveLinearProjectId(key(), args.id)
|
|
1075
|
+
if (resolved.error) return JSON.stringify({ error: resolved.error })
|
|
1076
|
+
const projectId = resolved.projectId
|
|
1077
|
+
if (!projectId) return JSON.stringify({ error: `Project not found: ${args.id}` })
|
|
1078
|
+
|
|
1079
|
+
// ProjectUpdateInput is flat (teamIds are UUIDs), and the mutation takes
|
|
1080
|
+
// the project UUID as a top-level `id` argument alongside `input`.
|
|
1081
|
+
const input = {}
|
|
1082
|
+
if (args.name !== undefined) input.name = args.name
|
|
1083
|
+
if (args.description !== undefined) input.description = args.description
|
|
1084
|
+
if (args.content !== undefined) input.content = args.content
|
|
1085
|
+
if (args.icon !== undefined) input.icon = args.icon
|
|
1086
|
+
if (args.color !== undefined) input.color = args.color
|
|
1087
|
+
if (args.startDate !== undefined) input.startDate = args.startDate
|
|
1088
|
+
if (args.targetDate !== undefined) input.targetDate = args.targetDate
|
|
1089
|
+
if (args.priority !== undefined) input.priority = args.priority
|
|
1090
|
+
if (args.teams !== undefined) {
|
|
1091
|
+
input.teamIds = await Promise.all((args.teams ?? []).map((t) => resolveTeamId(key(), t)))
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const data = await gql(key(), `
|
|
1095
|
+
mutation($id: String!, $input: ProjectUpdateInput!) {
|
|
1096
|
+
projectUpdate(id: $id, input: $input) {
|
|
1097
|
+
success project { ${PROJECT_FIELDS} }
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
`, { id: projectId, input })
|
|
1101
|
+
if (!data.projectUpdate.success) return JSON.stringify({ error: 'projectUpdate failed' })
|
|
1102
|
+
return JSON.stringify(data.projectUpdate.project, null, 2)
|
|
1103
|
+
},
|
|
1104
|
+
}),
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
register(
|
|
1108
|
+
defineTool({
|
|
1109
|
+
name: 'linear_label_create',
|
|
1110
|
+
description: 'Create a label for a team.',
|
|
1111
|
+
parameters: {
|
|
1112
|
+
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
1113
|
+
name: { type: 'string', required: true, description: 'Label name' },
|
|
1114
|
+
color: { type: 'string', description: 'Hex color, e.g. "#ff0000"' },
|
|
1115
|
+
description: { type: 'string', description: 'Label description' },
|
|
1116
|
+
},
|
|
1117
|
+
output: {
|
|
1118
|
+
schema: { type: 'string' },
|
|
1119
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1120
|
+
},
|
|
1121
|
+
async execute(args) {
|
|
1122
|
+
// LabelCreateInput takes a flat team UUID (teamId), not a nested key.
|
|
1123
|
+
const teamId = await resolveTeamId(key(), args.team)
|
|
1124
|
+
const data = await gql(key(), `
|
|
1125
|
+
mutation($input: IssueLabelCreateInput!) {
|
|
1126
|
+
issueLabelCreate(input: $input) {
|
|
1127
|
+
success label { ${LABEL_FIELDS} }
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
`, {
|
|
1131
|
+
input: {
|
|
1132
|
+
teamId,
|
|
1133
|
+
name: args.name,
|
|
1134
|
+
...(args.color ? { color: args.color } : {}),
|
|
1135
|
+
...(args.description ? { description: args.description } : {}),
|
|
1136
|
+
},
|
|
1137
|
+
})
|
|
1138
|
+
if (!data.issueLabelCreate.success) return JSON.stringify({ error: 'issueLabelCreate failed' })
|
|
1139
|
+
return JSON.stringify(data.issueLabelCreate.label, null, 2)
|
|
1140
|
+
},
|
|
1141
|
+
}),
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
// -- Cycles -----------------------------------------------------------------
|
|
1145
|
+
|
|
1146
|
+
register(
|
|
1147
|
+
defineTool({
|
|
1148
|
+
name: 'linear_cycle_list',
|
|
1149
|
+
description: 'List cycles for a team. Returns up to `limit` most recent cycles (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
1150
|
+
parameters: {
|
|
1151
|
+
team: { type: 'string', required: true, description: 'Team key, e.g. "APT"' },
|
|
1152
|
+
limit: { type: 'number', description: 'Max cycles (default 20, max 50)' },
|
|
1153
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
1154
|
+
},
|
|
1155
|
+
output: {
|
|
1156
|
+
schema: { type: 'string' },
|
|
1157
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1158
|
+
},
|
|
1159
|
+
async execute(args) {
|
|
1160
|
+
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
1161
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
1162
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
1163
|
+
const teamId = await resolveTeamId(key(), args.team)
|
|
1164
|
+
const vars = { teamId }
|
|
1165
|
+
if (after !== undefined) vars.after = after
|
|
1166
|
+
const varDecl = after !== undefined ? 'query($teamId: String!, $after: String)' : 'query($teamId: String!)'
|
|
1167
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
1168
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
1169
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
1170
|
+
const data = await gql(key(), `
|
|
1171
|
+
${varDecl} {
|
|
1172
|
+
cycles(first: ${limit}, filter: { team: { id: { eq: $teamId } } }${argList ? `, ${argList}` : ''}) {
|
|
1173
|
+
nodes { ${CYCLE_FIELDS} }
|
|
1174
|
+
${PAGE_INFO_FIELDS}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
`, vars)
|
|
1178
|
+
const cycles = data.cycles.nodes.map((c) => ({
|
|
1179
|
+
id: c.id,
|
|
1180
|
+
number: c.number,
|
|
1181
|
+
name: c.name,
|
|
1182
|
+
startsAt: c.startsAt,
|
|
1183
|
+
endsAt: c.endsAt,
|
|
1184
|
+
completedAt: c.completedAt,
|
|
1185
|
+
}))
|
|
1186
|
+
return JSON.stringify({ team: args.team, count: cycles.length, cycles, pageInfo: normalizePageInfo(data.cycles.pageInfo) }, null, 2)
|
|
1187
|
+
},
|
|
1188
|
+
}),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
register(
|
|
1192
|
+
defineTool({
|
|
1193
|
+
name: 'linear_cycle_get',
|
|
1194
|
+
description: 'Get a single cycle by its id (UUID). Returns full details including team, progress, and dates.',
|
|
1195
|
+
parameters: {
|
|
1196
|
+
id: { type: 'string', required: true, description: 'Cycle id (UUID)' },
|
|
1197
|
+
},
|
|
1198
|
+
output: {
|
|
1199
|
+
schema: { type: 'string' },
|
|
1200
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1201
|
+
},
|
|
1202
|
+
async execute(args) {
|
|
1203
|
+
const data = await gql(key(), `
|
|
1204
|
+
query($id: String!) {
|
|
1205
|
+
cycle: cycle(id: $id) {
|
|
1206
|
+
${CYCLE_FIELDS}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
`, { id: args.id })
|
|
1210
|
+
if (!data.cycle) return JSON.stringify({ error: `Cycle not found: ${args.id}` })
|
|
1211
|
+
return JSON.stringify(data.cycle, null, 2)
|
|
1212
|
+
},
|
|
1213
|
+
}),
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
// -- Users ------------------------------------------------------------------
|
|
1217
|
+
|
|
1218
|
+
register(
|
|
1219
|
+
defineTool({
|
|
1220
|
+
name: 'linear_user_list',
|
|
1221
|
+
description: 'List users in the workspace. Returns up to `limit` users (default 20, max 50). Cursor pagination: pass the previous page\'s pageInfo.endCursor as `after` to get the next page; the response includes pageInfo { hasNextPage, endCursor }.',
|
|
1222
|
+
parameters: {
|
|
1223
|
+
limit: { type: 'number', description: 'Max users (default 20, max 50)' },
|
|
1224
|
+
after: { type: 'string', description: 'Cursor from a previous page (pageInfo.endCursor) to fetch the next page' },
|
|
1225
|
+
},
|
|
1226
|
+
output: {
|
|
1227
|
+
schema: { type: 'string' },
|
|
1228
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1229
|
+
},
|
|
1230
|
+
async execute(args) {
|
|
1231
|
+
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? 20)), 50)
|
|
1232
|
+
// Optional cursor. Kept as a variable (`$after`), never interpolated.
|
|
1233
|
+
const after = typeof args.after === 'string' && args.after.trim() ? args.after : undefined
|
|
1234
|
+
const vars = {}
|
|
1235
|
+
if (after !== undefined) vars.after = after
|
|
1236
|
+
const varDecl = after !== undefined ? 'query($after: String)' : 'query'
|
|
1237
|
+
// One shared argument list: the cursor is declared only when it is also
|
|
1238
|
+
// passed, so a call with no `after` sends exactly the pre-pagination document.
|
|
1239
|
+
const argList = [after !== undefined ? 'after: $after' : null].filter(Boolean).join(', ')
|
|
1240
|
+
const data = await gql(key(), `
|
|
1241
|
+
${varDecl} {
|
|
1242
|
+
users(first: ${limit}${argList ? `, ${argList}` : ''}) {
|
|
1243
|
+
nodes { ${USER_FIELDS} }
|
|
1244
|
+
${PAGE_INFO_FIELDS}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
`, vars)
|
|
1248
|
+
return JSON.stringify({ count: data.users.nodes.length, users: data.users.nodes, pageInfo: normalizePageInfo(data.users.pageInfo) }, null, 2)
|
|
1249
|
+
},
|
|
1250
|
+
}),
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
register(
|
|
1254
|
+
defineTool({
|
|
1255
|
+
name: 'linear_user_get',
|
|
1256
|
+
description: 'Get a single user by id (UUID) or email. Returns full user details.',
|
|
1257
|
+
parameters: {
|
|
1258
|
+
id: { type: 'string', required: true, description: 'User id (UUID) or email address' },
|
|
1259
|
+
},
|
|
1260
|
+
output: {
|
|
1261
|
+
schema: { type: 'string' },
|
|
1262
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1263
|
+
},
|
|
1264
|
+
async execute(args) {
|
|
1265
|
+
let user
|
|
1266
|
+
if (UUID_RE.test(args.id)) {
|
|
1267
|
+
const data = await gql(key(), `
|
|
1268
|
+
query($id: String!) {
|
|
1269
|
+
user: user(id: $id) { ${USER_FIELDS} }
|
|
1270
|
+
}
|
|
1271
|
+
`, { id: args.id })
|
|
1272
|
+
user = data.user
|
|
1273
|
+
} else {
|
|
1274
|
+
const data = await gql(key(), `
|
|
1275
|
+
query($email: String!) {
|
|
1276
|
+
users(filter: { email: { eq: $email } }) {
|
|
1277
|
+
nodes { ${USER_FIELDS} }
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
`, { email: args.id })
|
|
1281
|
+
user = data.users.nodes[0] ?? null
|
|
1282
|
+
}
|
|
1283
|
+
if (!user) return JSON.stringify({ error: `User not found: ${args.id}` })
|
|
1284
|
+
return JSON.stringify(user, null, 2)
|
|
1285
|
+
},
|
|
1286
|
+
}),
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
// -- Single-item getters (project / label / team) ---------------------------
|
|
1290
|
+
|
|
1291
|
+
register(
|
|
1292
|
+
defineTool({
|
|
1293
|
+
name: 'linear_project_get',
|
|
1294
|
+
description: 'Get a single project by its id (UUID). Returns full project details including teams.',
|
|
1295
|
+
parameters: {
|
|
1296
|
+
id: { type: 'string', required: true, description: 'Project id (UUID)' },
|
|
1297
|
+
},
|
|
1298
|
+
output: {
|
|
1299
|
+
schema: { type: 'string' },
|
|
1300
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1301
|
+
},
|
|
1302
|
+
async execute(args) {
|
|
1303
|
+
const data = await gql(key(), `
|
|
1304
|
+
query($id: String!) {
|
|
1305
|
+
project: project(id: $id) {
|
|
1306
|
+
${PROJECT_FIELDS}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
`, { id: args.id })
|
|
1310
|
+
if (!data.project) return JSON.stringify({ error: `Project not found: ${args.id}` })
|
|
1311
|
+
return JSON.stringify(data.project, null, 2)
|
|
1312
|
+
},
|
|
1313
|
+
}),
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
register(
|
|
1317
|
+
defineTool({
|
|
1318
|
+
name: 'linear_label_get',
|
|
1319
|
+
description: 'Get a single label by its id (UUID). Returns full label details.',
|
|
1320
|
+
parameters: {
|
|
1321
|
+
id: { type: 'string', required: true, description: 'Label id (UUID)' },
|
|
1322
|
+
},
|
|
1323
|
+
output: {
|
|
1324
|
+
schema: { type: 'string' },
|
|
1325
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1326
|
+
},
|
|
1327
|
+
async execute(args) {
|
|
1328
|
+
const data = await gql(key(), `
|
|
1329
|
+
query($id: String!) {
|
|
1330
|
+
label: issueLabel(id: $id) {
|
|
1331
|
+
${LABEL_FIELDS}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
`, { id: args.id })
|
|
1335
|
+
if (!data.label) return JSON.stringify({ error: `Label not found: ${args.id}` })
|
|
1336
|
+
return JSON.stringify(data.label, null, 2)
|
|
1337
|
+
},
|
|
1338
|
+
}),
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
register(
|
|
1342
|
+
defineTool({
|
|
1343
|
+
name: 'linear_team_get',
|
|
1344
|
+
description: 'Get a single team by its id (UUID) or key (e.g. "APT"). Returns full team details.',
|
|
1345
|
+
parameters: {
|
|
1346
|
+
id: { type: 'string', required: true, description: 'Team id (UUID) or key like "APT"' },
|
|
1347
|
+
},
|
|
1348
|
+
output: {
|
|
1349
|
+
schema: { type: 'string' },
|
|
1350
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1351
|
+
},
|
|
1352
|
+
async execute(args) {
|
|
1353
|
+
let team
|
|
1354
|
+
if (UUID_RE.test(args.id)) {
|
|
1355
|
+
const data = await gql(key(), `
|
|
1356
|
+
query($id: String!) {
|
|
1357
|
+
team: team(id: $id) { ${TEAM_FIELDS} }
|
|
1358
|
+
}
|
|
1359
|
+
`, { id: args.id })
|
|
1360
|
+
team = data.team
|
|
1361
|
+
} else {
|
|
1362
|
+
// The singular `team` field takes only a UUID id; a key/name must be
|
|
1363
|
+
// resolved through the PLURAL `teams` field (which accepts a filter).
|
|
1364
|
+
const data = await gql(key(), `
|
|
1365
|
+
query($key: String!) {
|
|
1366
|
+
teams(first: 10, filter: { key: { eq: $key } }) {
|
|
1367
|
+
nodes { ${TEAM_FIELDS} }
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
`, { key: args.id })
|
|
1371
|
+
const nodes = data.teams?.nodes ?? []
|
|
1372
|
+
if (nodes.length !== 1) return JSON.stringify({ error: `Team not found: ${args.id}` })
|
|
1373
|
+
team = nodes[0]
|
|
1374
|
+
}
|
|
1375
|
+
if (!team) return JSON.stringify({ error: `Team not found: ${args.id}` })
|
|
1376
|
+
return JSON.stringify(team, null, 2)
|
|
1377
|
+
},
|
|
1378
|
+
}),
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
// -- Direct GraphQL escape hatch -------------------------------------------
|
|
1382
|
+
//
|
|
1383
|
+
// The one tool that is not a hand-modelled endpoint: it forwards the caller's
|
|
1384
|
+
// own GraphQL document to the SAME fixed endpoint (API_URL) with the SAME
|
|
1385
|
+
// configured key, through the same gql() client and the same { error } guard
|
|
1386
|
+
// every other tool runs under. There is no second endpoint, no extra config,
|
|
1387
|
+
// and no credential of its own.
|
|
1388
|
+
//
|
|
1389
|
+
// Queries AND mutations are allowed — that is the point of the escape hatch —
|
|
1390
|
+
// so what a call may actually do is bounded only by the permissions attached to
|
|
1391
|
+
// the configured Linear API key, exactly as they are for every other tool here.
|
|
1392
|
+
register(
|
|
1393
|
+
defineTool({
|
|
1394
|
+
name: 'linear_graphql',
|
|
1395
|
+
description:
|
|
1396
|
+
'Direct GraphQL escape hatch: send an arbitrary GraphQL document (query OR mutation) straight to the Linear API and get the raw `data` back as JSON. Use it for anything the named linear_* tools do not cover — coverage is whatever the Linear GraphQL API exposes to your key, so this is NOT a substitute for named-tool parity and nothing is projected or normalised. `query` is required and must be non-blank; `variables` is an optional object of GraphQL variable values (prefer variables over inlining values into the document). Both queries and mutations are sent as-is: what they may read or change is governed by your Linear API key permissions, so a mutation really writes.',
|
|
1397
|
+
parameters: {
|
|
1398
|
+
query: { type: 'string', required: true, description: 'The GraphQL document to execute (a query or a mutation)' },
|
|
1399
|
+
// Explicit properties keep common variable shapes documented; openness
|
|
1400
|
+
// stays true because the document decides which variables exist.
|
|
1401
|
+
variables: {
|
|
1402
|
+
type: 'object',
|
|
1403
|
+
additionalProperties: true,
|
|
1404
|
+
description: 'Optional GraphQL variables object, keyed by variable name (e.g. { "id": "<uuid>" })',
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
output: {
|
|
1408
|
+
schema: { type: 'string' },
|
|
1409
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
1410
|
+
},
|
|
1411
|
+
async execute(args) {
|
|
1412
|
+
// The parameter schema enforces the string; this is the non-blank half of
|
|
1413
|
+
// "required nonblank", checked before anything reaches the network.
|
|
1414
|
+
if (typeof args.query !== 'string' || !args.query.trim()) {
|
|
1415
|
+
return JSON.stringify({ error: '"query" must be a non-empty GraphQL document' })
|
|
1416
|
+
}
|
|
1417
|
+
const data = await gql(key(), args.query, args.variables)
|
|
1418
|
+
// Raw pass-through: the document chooses the shape, so the result is
|
|
1419
|
+
// returned as-is rather than projected through a fixed field set.
|
|
1420
|
+
return JSON.stringify({ data }, null, 2)
|
|
1421
|
+
},
|
|
1422
|
+
}),
|
|
1423
|
+
)
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ---------------------------------------------------------------------------
|
|
1427
|
+
// Plugin surface
|
|
1428
|
+
// ---------------------------------------------------------------------------
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Plugin config:
|
|
1432
|
+
* apiKey: Linear personal API key (li_api_...) for the GraphQL tools.
|
|
1433
|
+
* Falls back to the LINEAR_API_KEY environment variable when unset.
|
|
1434
|
+
*
|
|
1435
|
+
* There is no other config. The plugin has no MCP side: it neither hosts an
|
|
1436
|
+
* MCP server nor connects to one, and it reads no MCP-related setting or
|
|
1437
|
+
* environment variable.
|
|
1438
|
+
*/
|
|
1439
|
+
// Cordis validates a plugin's `Config` as a Standard Schema (it calls
|
|
1440
|
+
// Config["~standard"].validate(...)). The real API key still comes from the
|
|
1441
|
+
// LINEAR_API_KEY env var (see resolveKey); this schema only needs to accept an
|
|
1442
|
+
// optional `apiKey` string so a patch row may also supply one. `.default('')`
|
|
1443
|
+
// makes a missing field valid; the empty value is ignored by resolveKey, which
|
|
1444
|
+
// prefers config.apiKey only when it is non-empty and otherwise falls back to env.
|
|
1445
|
+
const Config = z.object({
|
|
1446
|
+
apiKey: z.string().default(''),
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
function apply(ctx, config) {
|
|
1450
|
+
registerLinearTools(ctx, config)
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Cordis's namespace unwrap keys off { name, inject, apply }; this module
|
|
1454
|
+
// exports exactly that plugin surface and nothing else.
|
|
1455
|
+
export {
|
|
1456
|
+
Config,
|
|
1457
|
+
apply,
|
|
1458
|
+
inject,
|
|
1459
|
+
name,
|
|
1460
|
+
}
|