@meetopenbot/linear 0.0.3 → 0.0.5

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/dist/oauth.js DELETED
@@ -1,381 +0,0 @@
1
- /**
2
- * Linear OAuth 2.0 (authorization code + PKCE).
3
- *
4
- * Two callback modes:
5
- * - Loopback: temporary localhost server (local runtimes).
6
- * - Webhook: redirect to `https://<host>/api/webhooks/linear` (cloud/remote).
7
- */
8
- import { createHash, randomBytes } from "node:crypto";
9
- import { createServer } from "node:http";
10
- import { GO_BACK_TO_OPENBOT_URL } from "./config.js";
11
- import { clearPendingOAuthSession, loadPendingOAuthSession, savePendingOAuthSession, } from "./oauth-pending.js";
12
- export const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
13
- export const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
14
- export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
15
- export const OAUTH_WEBHOOK_PROVIDER = "linear";
16
- const LOOPBACK_CALLBACK_PATH = "/oauth/callback";
17
- const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
18
- const PENDING_SESSION_MS = 10 * 60 * 1000;
19
- let activeServer = null;
20
- const webhookOAuthCompletions = new Map();
21
- function base64url(buffer) {
22
- return buffer
23
- .toString("base64")
24
- .replace(/\+/g, "-")
25
- .replace(/\//g, "_")
26
- .replace(/=+$/, "");
27
- }
28
- export function oauthHtmlPage(title, body, ok) {
29
- return `<!doctype html>
30
- <html>
31
- <head>
32
- <meta charset="utf-8" />
33
- <meta name="viewport" content="width=device-width, initial-scale=1" />
34
- <title>${title}</title>
35
- <link rel="preconnect" href="https://fonts.googleapis.com" />
36
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
37
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
38
- <style>
39
- body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #101012; color: #ededef; }
40
- .card { text-align: center; padding: 40px 48px; border-radius: 14px; background: #1b1b1f; border: 1px solid #2a2a30; max-width: 420px; }
41
- .icon { height: 28px; margin-bottom: 18px; font-size: 28px; font-weight: 500; line-height: 28px; }
42
- .success { color: #4ade80; }
43
- h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
44
- p { color: #9b9ba3; margin: 0; line-height: 1.5; }
45
- .back-link { margin: 20px 0 0; font-size: 14px; }
46
- .back-link a { color: #ededef; text-decoration: none; }
47
- .back-link a:hover { text-decoration: underline; }
48
- </style>
49
- </head>
50
- <body>
51
- <div class="card">
52
- <div class="icon${ok ? " success" : ""}">${ok ? "✓" : "⚠"}</div>
53
- <h1>${title}</h1>
54
- <p>${body}</p>
55
- <p class="back-link">Go back to <a href="${GO_BACK_TO_OPENBOT_URL}">OpenBot</a></p>
56
- </div>
57
- </body>
58
- </html>`;
59
- }
60
- export function buildOAuthRedirectUri(webhookBaseUrl) {
61
- const base = webhookBaseUrl.replace(/\/$/, "");
62
- return `${base}/api/webhooks/${OAUTH_WEBHOOK_PROVIDER}`;
63
- }
64
- function generatePkce() {
65
- const state = base64url(randomBytes(24));
66
- const codeVerifier = base64url(randomBytes(48));
67
- const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
68
- return { state, codeVerifier, codeChallenge };
69
- }
70
- export function buildAuthorizeUrl(args) {
71
- return (`${LINEAR_AUTHORIZE_URL}?` +
72
- new URLSearchParams({
73
- client_id: args.clientId,
74
- redirect_uri: args.redirectUri,
75
- response_type: "code",
76
- scope: args.scopes,
77
- state: args.state,
78
- prompt: "consent",
79
- code_challenge: args.codeChallenge,
80
- code_challenge_method: "S256",
81
- }).toString());
82
- }
83
- export async function exchangeAuthorizationCode(args) {
84
- const body = new URLSearchParams({
85
- grant_type: "authorization_code",
86
- code: args.code,
87
- redirect_uri: args.redirectUri,
88
- client_id: args.clientId,
89
- code_verifier: args.codeVerifier,
90
- });
91
- if (args.clientSecret)
92
- body.set("client_secret", args.clientSecret);
93
- const response = await fetch(LINEAR_TOKEN_URL, {
94
- method: "POST",
95
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
96
- body,
97
- });
98
- const payload = (await response.json().catch(() => ({})));
99
- if (!response.ok || typeof payload.access_token !== "string") {
100
- const detail = typeof payload.error_description === "string"
101
- ? payload.error_description
102
- : JSON.stringify(payload);
103
- throw new Error(`Linear token exchange failed (${response.status}): ${detail}`);
104
- }
105
- return normalizeTokenResponse(payload);
106
- }
107
- export async function refreshAccessToken(args) {
108
- const body = new URLSearchParams({
109
- grant_type: "refresh_token",
110
- refresh_token: args.refreshToken,
111
- client_id: args.clientId,
112
- });
113
- if (args.clientSecret)
114
- body.set("client_secret", args.clientSecret);
115
- const response = await fetch(LINEAR_TOKEN_URL, {
116
- method: "POST",
117
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
118
- body,
119
- });
120
- const payload = (await response.json().catch(() => ({})));
121
- if (!response.ok || typeof payload.access_token !== "string") {
122
- const detail = typeof payload.error_description === "string"
123
- ? payload.error_description
124
- : JSON.stringify(payload);
125
- throw new Error(`Linear token refresh failed (${response.status}): ${detail}`);
126
- }
127
- return normalizeTokenResponse(payload);
128
- }
129
- function normalizeTokenResponse(payload) {
130
- return {
131
- accessToken: payload.access_token,
132
- refreshToken: typeof payload.refresh_token === "string"
133
- ? payload.refresh_token
134
- : undefined,
135
- expiresAt: typeof payload.expires_in === "number"
136
- ? Date.now() + payload.expires_in * 1000
137
- : undefined,
138
- scope: Array.isArray(payload.scope)
139
- ? payload.scope.join(",")
140
- : payload.scope,
141
- };
142
- }
143
- function registerWebhookOAuthCompletion(state, timeoutMs) {
144
- return new Promise((resolve) => {
145
- const existing = webhookOAuthCompletions.get(state);
146
- if (existing) {
147
- webhookOAuthCompletions.delete(state);
148
- }
149
- const timeout = setTimeout(() => {
150
- webhookOAuthCompletions.delete(state);
151
- resolve(null);
152
- }, timeoutMs);
153
- timeout.unref?.();
154
- webhookOAuthCompletions.set(state, (tokens) => {
155
- clearTimeout(timeout);
156
- resolve(tokens);
157
- });
158
- });
159
- }
160
- function settleWebhookOAuthCompletion(state, tokens) {
161
- const resolve = webhookOAuthCompletions.get(state);
162
- if (!resolve)
163
- return;
164
- webhookOAuthCompletions.delete(state);
165
- resolve(tokens);
166
- }
167
- export async function startWebhookOAuthFlow(args) {
168
- const { state, codeVerifier, codeChallenge } = generatePkce();
169
- const redirectUri = buildOAuthRedirectUri(args.webhookBaseUrl);
170
- const pending = {
171
- state,
172
- codeVerifier,
173
- clientId: args.clientId,
174
- clientSecret: args.clientSecret,
175
- redirectUri,
176
- expiresAt: Date.now() + PENDING_SESSION_MS,
177
- };
178
- await savePendingOAuthSession(args.storage, pending);
179
- const authorizeUrl = buildAuthorizeUrl({
180
- clientId: args.clientId,
181
- redirectUri,
182
- scopes: args.scopes,
183
- state,
184
- codeChallenge,
185
- });
186
- const timeoutMs = args.timeoutMs ?? DEFAULT_TIMEOUT_MS;
187
- const completion = registerWebhookOAuthCompletion(state, timeoutMs);
188
- return {
189
- authorizeUrl,
190
- redirectUri,
191
- completion,
192
- cancel: () => {
193
- settleWebhookOAuthCompletion(state, null);
194
- void clearPendingOAuthSession(args.storage);
195
- },
196
- };
197
- }
198
- export async function handleWebhookOAuthCallback(args) {
199
- const code = queryParam(args.query, "code");
200
- const state = queryParam(args.query, "state");
201
- const oauthError = queryParam(args.query, "error");
202
- if (!code && !state && !oauthError) {
203
- return { kind: "ignore" };
204
- }
205
- if (oauthError) {
206
- if (state)
207
- settleWebhookOAuthCompletion(state, null);
208
- await clearPendingOAuthSession(args.storage);
209
- return {
210
- kind: "oauth",
211
- status: 400,
212
- html: oauthHtmlPage("Connection failed", `Linear returned: ${oauthError}. Return to OpenBot and try again.`, false),
213
- tokens: null,
214
- state,
215
- };
216
- }
217
- const pending = await loadPendingOAuthSession(args.storage);
218
- if (!pending || !code || !state || pending.state !== state) {
219
- return {
220
- kind: "oauth",
221
- status: 400,
222
- html: oauthHtmlPage("Connection failed", "Invalid callback (missing code or state mismatch). Return to OpenBot and try again.", false),
223
- tokens: null,
224
- state,
225
- };
226
- }
227
- try {
228
- const tokens = await exchangeAuthorizationCode({
229
- code,
230
- redirectUri: pending.redirectUri,
231
- clientId: pending.clientId,
232
- clientSecret: pending.clientSecret,
233
- codeVerifier: pending.codeVerifier,
234
- });
235
- await args.onSuccess(tokens);
236
- await clearPendingOAuthSession(args.storage);
237
- settleWebhookOAuthCompletion(state, tokens);
238
- return {
239
- kind: "oauth",
240
- status: 200,
241
- html: oauthHtmlPage("Connected to Linear", "You can close this tab and return to OpenBot.", true),
242
- tokens,
243
- state,
244
- };
245
- }
246
- catch (error) {
247
- const message = error instanceof Error ? error.message : String(error);
248
- await clearPendingOAuthSession(args.storage);
249
- settleWebhookOAuthCompletion(state, null);
250
- return {
251
- kind: "oauth",
252
- status: 500,
253
- html: oauthHtmlPage("Connection failed", `${message}. Return to OpenBot and try again.`, false),
254
- tokens: null,
255
- state,
256
- };
257
- }
258
- }
259
- function queryParam(query, key) {
260
- const value = query[key];
261
- if (typeof value === "string" && value.trim())
262
- return value.trim();
263
- if (Array.isArray(value) && typeof value[0] === "string") {
264
- return value[0].trim();
265
- }
266
- return undefined;
267
- }
268
- export function startOAuthFlow(args) {
269
- if (activeServer) {
270
- activeServer.close();
271
- activeServer = null;
272
- }
273
- const { state, codeVerifier, codeChallenge } = generatePkce();
274
- const redirectUri = `http://localhost:${args.port}${LOOPBACK_CALLBACK_PATH}`;
275
- const authorizeUrl = buildAuthorizeUrl({
276
- clientId: args.clientId,
277
- redirectUri,
278
- scopes: args.scopes,
279
- state,
280
- codeChallenge,
281
- });
282
- let settle;
283
- const completion = new Promise((resolve) => {
284
- settle = resolve;
285
- });
286
- const server = createServer(async (req, res) => {
287
- const url = new URL(req.url ?? "/", `http://localhost:${args.port}`);
288
- if (url.pathname !== LOOPBACK_CALLBACK_PATH) {
289
- res.writeHead(404).end();
290
- return;
291
- }
292
- const finish = (status, title, body, ok) => {
293
- res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
294
- res.end(oauthHtmlPage(title, body, ok));
295
- };
296
- const error = url.searchParams.get("error");
297
- if (error) {
298
- finish(400, "Connection failed", `Linear returned: ${error}. Return to OpenBot and try again.`, false);
299
- cleanup();
300
- args.onError?.(new Error(`Linear authorization failed: ${error}`));
301
- settle(null);
302
- return;
303
- }
304
- const code = url.searchParams.get("code");
305
- if (!code || url.searchParams.get("state") !== state) {
306
- finish(400, "Connection failed", "Invalid callback (missing code or state mismatch). Return to OpenBot and try again.", false);
307
- return;
308
- }
309
- try {
310
- const tokens = await exchangeAuthorizationCode({
311
- code,
312
- redirectUri,
313
- clientId: args.clientId,
314
- clientSecret: args.clientSecret,
315
- codeVerifier,
316
- });
317
- await args.onSuccess(tokens);
318
- finish(200, "Connected to Linear", "You can close this tab and return to OpenBot.", true);
319
- cleanup();
320
- settle(tokens);
321
- }
322
- catch (error) {
323
- const message = error instanceof Error ? error.message : String(error);
324
- finish(500, "Connection failed", `${message}. Return to OpenBot and try again.`, false);
325
- cleanup();
326
- args.onError?.(error instanceof Error ? error : new Error(message));
327
- settle(null);
328
- }
329
- });
330
- const timeout = setTimeout(() => {
331
- cleanup();
332
- settle(null);
333
- }, args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
334
- timeout.unref?.();
335
- function cleanup() {
336
- clearTimeout(timeout);
337
- if (activeServer === server)
338
- activeServer = null;
339
- server.close();
340
- }
341
- server.on("error", (error) => {
342
- cleanup();
343
- args.onError?.(error);
344
- settle(null);
345
- });
346
- server.listen(args.port);
347
- activeServer = server;
348
- return {
349
- authorizeUrl,
350
- redirectUri,
351
- completion,
352
- cancel: () => {
353
- cleanup();
354
- settle(null);
355
- },
356
- };
357
- }
358
- export async function fetchViewer(accessToken) {
359
- const response = await fetch(LINEAR_GRAPHQL_URL, {
360
- method: "POST",
361
- headers: {
362
- "Content-Type": "application/json",
363
- Authorization: `Bearer ${accessToken}`,
364
- },
365
- body: JSON.stringify({
366
- query: `query { viewer { id name displayName email } organization { id name urlKey } }`,
367
- }),
368
- });
369
- if (!response.ok) {
370
- const body = await response.text().catch(() => "");
371
- throw new Error(`Linear API request failed (${response.status}): ${body.slice(0, 500)}`);
372
- }
373
- const payload = (await response.json());
374
- if (payload.errors?.length) {
375
- throw new Error(payload.errors.map((e) => e.message).join("; "));
376
- }
377
- if (!payload.data) {
378
- throw new Error("Linear API returned no data.");
379
- }
380
- return payload.data;
381
- }
package/dist/tools.d.ts DELETED
@@ -1,17 +0,0 @@
1
- /**
2
- * Linear tool implementations. Each tool returns structured `data` for the
3
- * model plus a human-readable `output` summary, and optionally a UI widget
4
- * spec rendered in the OpenBot client.
5
- */
6
- import type { RenderUIWidgetData, ToolDefinition } from '@meetopenbot/plugin-sdk';
7
- import { type LinearAuth } from './api.js';
8
- export interface ToolRunResult {
9
- data: unknown;
10
- output: string;
11
- widget?: RenderUIWidgetData;
12
- }
13
- export interface LinearTool {
14
- definition: ToolDefinition;
15
- run: (auth: LinearAuth, args: Record<string, unknown>) => Promise<ToolRunResult>;
16
- }
17
- export declare const linearTools: Record<string, LinearTool>;
package/dist/tools.js DELETED
@@ -1,266 +0,0 @@
1
- /**
2
- * Linear tool implementations. Each tool returns structured `data` for the
3
- * model plus a human-readable `output` summary, and optionally a UI widget
4
- * spec rendered in the OpenBot client.
5
- */
6
- import { z } from 'zod';
7
- import { ISSUE_FIELDS, LinearApiError, fetchViewer, formatIssue, linearRequest, resolveTeam, } from './api.js';
8
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
- async function resolveIssueId(auth, idOrIdentifier) {
10
- if (UUID_RE.test(idOrIdentifier))
11
- return idOrIdentifier;
12
- const issue = await findIssueByIdentifier(auth, idOrIdentifier);
13
- return issue.id;
14
- }
15
- async function findIssueByIdentifier(auth, identifier) {
16
- const data = await linearRequest(auth, `query($term: String!) { searchIssues(term: $term, first: 10) { nodes { ${ISSUE_FIELDS} } } }`, { term: identifier });
17
- const match = data.searchIssues.nodes.find((n) => n.identifier.toLowerCase() === identifier.toLowerCase());
18
- if (!match)
19
- throw new LinearApiError(`No Linear issue found with identifier "${identifier}".`);
20
- return match;
21
- }
22
- function issueListWidget(title, issues) {
23
- return {
24
- kind: 'list',
25
- title,
26
- items: issues.map((issue) => ({
27
- id: issue.id,
28
- label: `${issue.identifier} · ${issue.title}`,
29
- description: [issue.assignee?.displayName, issue.project?.name].filter(Boolean).join(' · ') || undefined,
30
- status: issue.state?.name,
31
- statusVariant: issue.state?.type === 'completed'
32
- ? 'success'
33
- : issue.state?.type === 'started'
34
- ? 'info'
35
- : issue.state?.type === 'canceled'
36
- ? 'danger'
37
- : 'default',
38
- metadata: { url: issue.url },
39
- })),
40
- };
41
- }
42
- export const linearTools = {
43
- linear_status: {
44
- definition: {
45
- description: 'Check whether Linear is connected and which user/workspace the credentials belong to.',
46
- // The openbot runtime plugin passes inputSchema straight to the AI SDK,
47
- // which requires Zod schemas (plain JSON Schema objects fail).
48
- inputSchema: z.object({}),
49
- },
50
- run: async (auth) => {
51
- const { viewer, organization } = await fetchViewer(auth);
52
- return {
53
- data: { connected: true, viewer, organization, authKind: auth.kind },
54
- output: `Connected to Linear workspace "${organization.name}" as ${viewer.displayName ?? viewer.name} (${viewer.email}) via ${auth.kind === 'apiKey' ? 'API key' : 'OAuth'}.`,
55
- };
56
- },
57
- },
58
- list_teams: {
59
- definition: {
60
- description: 'List Linear teams, including their workflow states (useful for setting issue status).',
61
- inputSchema: z.object({}),
62
- },
63
- run: async (auth) => {
64
- const data = await linearRequest(auth, `query { teams(first: 100) { nodes { id key name states { nodes { id name type } } } } }`);
65
- const teams = data.teams.nodes;
66
- return {
67
- data: teams,
68
- output: `Found ${teams.length} team(s): ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`,
69
- };
70
- },
71
- },
72
- list_issues: {
73
- definition: {
74
- description: 'List Linear issues, optionally filtered by team (key, name, or id), assignee ("me" or a user id), and state type.',
75
- inputSchema: z.object({
76
- team: z.string().optional().describe('Team key (e.g. "ENG"), name, or id'),
77
- assignee: z.string().optional().describe('"me" for the connected user, or a Linear user id'),
78
- stateType: z
79
- .enum(['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled'])
80
- .optional()
81
- .describe('Filter by workflow state type'),
82
- limit: z.number().optional().describe('Max issues to return (default 25)'),
83
- }),
84
- },
85
- run: async (auth, args) => {
86
- const { team, assignee, stateType, limit } = args;
87
- const filter = {};
88
- if (team)
89
- filter.team = { id: { eq: (await resolveTeam(auth, team)).id } };
90
- if (stateType)
91
- filter.state = { type: { eq: stateType } };
92
- if (assignee) {
93
- if (assignee === 'me') {
94
- const { viewer } = await fetchViewer(auth);
95
- filter.assignee = { id: { eq: viewer.id } };
96
- }
97
- else {
98
- filter.assignee = { id: { eq: assignee } };
99
- }
100
- }
101
- const data = await linearRequest(auth, `query($filter: IssueFilter, $first: Int!) { issues(filter: $filter, first: $first, orderBy: updatedAt) { nodes { ${ISSUE_FIELDS} } } }`, { filter, first: Math.min(limit ?? 25, 100) });
102
- const issues = data.issues.nodes;
103
- return {
104
- data: issues.map(formatIssue),
105
- output: `Found ${issues.length} issue(s).`,
106
- widget: issueListWidget('Linear Issues', issues),
107
- };
108
- },
109
- },
110
- search_issues: {
111
- definition: {
112
- description: 'Full-text search Linear issues by keyword.',
113
- inputSchema: z.object({
114
- query: z.string().describe('Search term'),
115
- limit: z.number().optional().describe('Max results (default 10)'),
116
- }),
117
- },
118
- run: async (auth, args) => {
119
- const { query, limit } = args;
120
- const data = await linearRequest(auth, `query($term: String!, $first: Int!) { searchIssues(term: $term, first: $first) { nodes { ${ISSUE_FIELDS} } } }`, { term: query, first: Math.min(limit ?? 10, 50) });
121
- const issues = data.searchIssues.nodes;
122
- return {
123
- data: issues.map(formatIssue),
124
- output: `Found ${issues.length} issue(s) matching "${query}".`,
125
- widget: issueListWidget(`Search: ${query}`, issues),
126
- };
127
- },
128
- },
129
- get_issue: {
130
- definition: {
131
- description: 'Get full details of a Linear issue by identifier (e.g. "ENG-123") or id, including comments.',
132
- inputSchema: z.object({
133
- issue: z.string().describe('Issue identifier (e.g. "ENG-123") or Linear issue id'),
134
- }),
135
- },
136
- run: async (auth, args) => {
137
- const { issue: issueRef } = args;
138
- const issueId = await resolveIssueId(auth, issueRef);
139
- const data = await linearRequest(auth, `query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} comments(first: 25) { nodes { body createdAt user { displayName } } } } }`, { id: issueId });
140
- const issue = data.issue;
141
- const comments = issue.comments.nodes.map((c) => ({
142
- author: c.user?.displayName,
143
- createdAt: c.createdAt,
144
- body: c.body,
145
- }));
146
- return {
147
- data: { ...formatIssue(issue), comments },
148
- output: `Retrieved ${issue.identifier}: ${issue.title} (${issue.state?.name ?? 'unknown state'}).`,
149
- };
150
- },
151
- },
152
- create_issue: {
153
- definition: {
154
- description: 'Create a new Linear issue.',
155
- inputSchema: z.object({
156
- team: z.string().describe('Team key (e.g. "ENG"), name, or id'),
157
- title: z.string().describe('Issue title'),
158
- description: z.string().optional().describe('Issue description (markdown)'),
159
- priority: z.number().optional().describe('0 none, 1 urgent, 2 high, 3 medium, 4 low'),
160
- assignee: z.string().optional().describe('"me" or a Linear user id'),
161
- stateId: z.string().optional().describe('Workflow state id (see list_teams)'),
162
- }),
163
- },
164
- run: async (auth, args) => {
165
- const { team, title, description, priority, assignee, stateId } = args;
166
- const teamId = (await resolveTeam(auth, team)).id;
167
- let assigneeId = assignee;
168
- if (assignee === 'me')
169
- assigneeId = (await fetchViewer(auth)).viewer.id;
170
- const data = await linearRequest(auth, `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input: { teamId, title, description, priority, assigneeId, stateId } });
171
- const issue = data.issueCreate.issue;
172
- return {
173
- data: formatIssue(issue),
174
- output: `Created ${issue.identifier}: ${issue.title} — ${issue.url}`,
175
- };
176
- },
177
- },
178
- update_issue: {
179
- definition: {
180
- description: 'Update a Linear issue (title, description, priority, state, assignee).',
181
- inputSchema: z.object({
182
- issue: z.string().describe('Issue identifier (e.g. "ENG-123") or id'),
183
- title: z.string().optional().describe('New title'),
184
- description: z.string().optional().describe('New description (markdown)'),
185
- priority: z.number().optional().describe('0 none, 1 urgent, 2 high, 3 medium, 4 low'),
186
- assignee: z.string().optional().describe('"me", a Linear user id, or "none" to unassign'),
187
- stateId: z.string().optional().describe('Workflow state id (see list_teams)'),
188
- }),
189
- },
190
- run: async (auth, args) => {
191
- const { issue: issueRef, title, description, priority, assignee, stateId } = args;
192
- const issueId = await resolveIssueId(auth, issueRef);
193
- let assigneeId = assignee;
194
- if (assignee === 'me')
195
- assigneeId = (await fetchViewer(auth)).viewer.id;
196
- if (assignee === 'none')
197
- assigneeId = null;
198
- const data = await linearRequest(auth, `mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { id: issueId, input: { title, description, priority, assigneeId, stateId } });
199
- const issue = data.issueUpdate.issue;
200
- return {
201
- data: formatIssue(issue),
202
- output: `Updated ${issue.identifier}: ${issue.title} (${issue.state?.name ?? 'unknown state'}).`,
203
- };
204
- },
205
- },
206
- create_comment: {
207
- definition: {
208
- description: 'Add a comment to a Linear issue.',
209
- inputSchema: z.object({
210
- issue: z.string().describe('Issue identifier (e.g. "ENG-123") or id'),
211
- body: z.string().describe('Comment body (markdown)'),
212
- }),
213
- },
214
- run: async (auth, args) => {
215
- const { issue: issueRef, body } = args;
216
- const issueId = await resolveIssueId(auth, issueRef);
217
- await linearRequest(auth, `mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`, { input: { issueId, body } });
218
- return {
219
- data: { success: true },
220
- output: `Comment added to ${issueRef}.`,
221
- };
222
- },
223
- },
224
- list_projects: {
225
- definition: {
226
- description: 'List Linear projects.',
227
- inputSchema: z.object({
228
- limit: z.number().optional().describe('Max projects to return (default 25)'),
229
- }),
230
- },
231
- run: async (auth, args) => {
232
- const { limit } = args;
233
- const data = await linearRequest(auth, `query($first: Int!) { projects(first: $first) { nodes { id name state progress url lead { displayName } } } }`, { first: Math.min(limit ?? 25, 100) });
234
- const projects = data.projects.nodes;
235
- return {
236
- data: projects,
237
- output: `Found ${projects.length} project(s): ${projects.map((p) => p.name).join(', ')}`,
238
- widget: {
239
- kind: 'list',
240
- title: 'Linear Projects',
241
- items: projects.map((p) => ({
242
- id: p.id,
243
- label: p.name,
244
- description: p.lead?.displayName ? `Lead: ${p.lead.displayName}` : undefined,
245
- status: p.state,
246
- metadata: { url: p.url },
247
- })),
248
- },
249
- };
250
- },
251
- },
252
- list_users: {
253
- definition: {
254
- description: 'List members of the Linear workspace (useful for resolving assignee ids).',
255
- inputSchema: z.object({}),
256
- },
257
- run: async (auth) => {
258
- const data = await linearRequest(auth, `query { users(first: 100) { nodes { id name displayName email active } } }`);
259
- const users = data.users.nodes.filter((u) => u.active);
260
- return {
261
- data: users,
262
- output: `Found ${users.length} active user(s).`,
263
- };
264
- },
265
- },
266
- };