@meetopenbot/linear 0.0.1

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/tools.js ADDED
@@ -0,0 +1,290 @@
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 { ISSUE_FIELDS, LinearApiError, fetchViewer, formatIssue, linearRequest, resolveTeam, } from './api.js';
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ async function resolveIssueId(auth, idOrIdentifier) {
9
+ if (UUID_RE.test(idOrIdentifier))
10
+ return idOrIdentifier;
11
+ const issue = await findIssueByIdentifier(auth, idOrIdentifier);
12
+ return issue.id;
13
+ }
14
+ async function findIssueByIdentifier(auth, identifier) {
15
+ const data = await linearRequest(auth, `query($term: String!) { searchIssues(term: $term, first: 10) { nodes { ${ISSUE_FIELDS} } } }`, { term: identifier });
16
+ const match = data.searchIssues.nodes.find((n) => n.identifier.toLowerCase() === identifier.toLowerCase());
17
+ if (!match)
18
+ throw new LinearApiError(`No Linear issue found with identifier "${identifier}".`);
19
+ return match;
20
+ }
21
+ function issueListWidget(title, issues) {
22
+ return {
23
+ kind: 'list',
24
+ title,
25
+ items: issues.map((issue) => ({
26
+ id: issue.id,
27
+ label: `${issue.identifier} · ${issue.title}`,
28
+ description: [issue.assignee?.displayName, issue.project?.name].filter(Boolean).join(' · ') || undefined,
29
+ status: issue.state?.name,
30
+ statusVariant: issue.state?.type === 'completed'
31
+ ? 'success'
32
+ : issue.state?.type === 'started'
33
+ ? 'info'
34
+ : issue.state?.type === 'canceled'
35
+ ? 'danger'
36
+ : 'default',
37
+ metadata: { url: issue.url },
38
+ })),
39
+ };
40
+ }
41
+ export const linearTools = {
42
+ linear_status: {
43
+ definition: {
44
+ description: 'Check whether Linear is connected and which user/workspace the credentials belong to.',
45
+ inputSchema: { type: 'object', properties: {} },
46
+ },
47
+ run: async (auth) => {
48
+ const { viewer, organization } = await fetchViewer(auth);
49
+ return {
50
+ data: { connected: true, viewer, organization, authKind: auth.kind },
51
+ output: `Connected to Linear workspace "${organization.name}" as ${viewer.displayName ?? viewer.name} (${viewer.email}) via ${auth.kind === 'apiKey' ? 'API key' : 'OAuth'}.`,
52
+ };
53
+ },
54
+ },
55
+ list_teams: {
56
+ definition: {
57
+ description: 'List Linear teams, including their workflow states (useful for setting issue status).',
58
+ inputSchema: { type: 'object', properties: {} },
59
+ },
60
+ run: async (auth) => {
61
+ const data = await linearRequest(auth, `query { teams(first: 100) { nodes { id key name states { nodes { id name type } } } } }`);
62
+ const teams = data.teams.nodes;
63
+ return {
64
+ data: teams,
65
+ output: `Found ${teams.length} team(s): ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`,
66
+ };
67
+ },
68
+ },
69
+ list_issues: {
70
+ definition: {
71
+ description: 'List Linear issues, optionally filtered by team (key, name, or id), assignee ("me" or a user id), and state type.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ team: { type: 'string', description: 'Team key (e.g. "ENG"), name, or id' },
76
+ assignee: { type: 'string', description: '"me" for the connected user, or a Linear user id' },
77
+ stateType: {
78
+ type: 'string',
79
+ enum: ['triage', 'backlog', 'unstarted', 'started', 'completed', 'canceled'],
80
+ description: 'Filter by workflow state type',
81
+ },
82
+ limit: { type: 'number', description: 'Max issues to return (default 25)' },
83
+ },
84
+ },
85
+ },
86
+ run: async (auth, args) => {
87
+ const { team, assignee, stateType, limit } = args;
88
+ const filter = {};
89
+ if (team)
90
+ filter.team = { id: { eq: (await resolveTeam(auth, team)).id } };
91
+ if (stateType)
92
+ filter.state = { type: { eq: stateType } };
93
+ if (assignee) {
94
+ if (assignee === 'me') {
95
+ const { viewer } = await fetchViewer(auth);
96
+ filter.assignee = { id: { eq: viewer.id } };
97
+ }
98
+ else {
99
+ filter.assignee = { id: { eq: assignee } };
100
+ }
101
+ }
102
+ 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) });
103
+ const issues = data.issues.nodes;
104
+ return {
105
+ data: issues.map(formatIssue),
106
+ output: `Found ${issues.length} issue(s).`,
107
+ widget: issueListWidget('Linear Issues', issues),
108
+ };
109
+ },
110
+ },
111
+ search_issues: {
112
+ definition: {
113
+ description: 'Full-text search Linear issues by keyword.',
114
+ inputSchema: {
115
+ type: 'object',
116
+ properties: {
117
+ query: { type: 'string', description: 'Search term' },
118
+ limit: { type: 'number', description: 'Max results (default 10)' },
119
+ },
120
+ required: ['query'],
121
+ },
122
+ },
123
+ run: async (auth, args) => {
124
+ const { query, limit } = args;
125
+ 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) });
126
+ const issues = data.searchIssues.nodes;
127
+ return {
128
+ data: issues.map(formatIssue),
129
+ output: `Found ${issues.length} issue(s) matching "${query}".`,
130
+ widget: issueListWidget(`Search: ${query}`, issues),
131
+ };
132
+ },
133
+ },
134
+ get_issue: {
135
+ definition: {
136
+ description: 'Get full details of a Linear issue by identifier (e.g. "ENG-123") or id, including comments.',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ issue: { type: 'string', description: 'Issue identifier (e.g. "ENG-123") or Linear issue id' },
141
+ },
142
+ required: ['issue'],
143
+ },
144
+ },
145
+ run: async (auth, args) => {
146
+ const { issue: issueRef } = args;
147
+ const issueId = await resolveIssueId(auth, issueRef);
148
+ const data = await linearRequest(auth, `query($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} comments(first: 25) { nodes { body createdAt user { displayName } } } } }`, { id: issueId });
149
+ const issue = data.issue;
150
+ const comments = issue.comments.nodes.map((c) => ({
151
+ author: c.user?.displayName,
152
+ createdAt: c.createdAt,
153
+ body: c.body,
154
+ }));
155
+ return {
156
+ data: { ...formatIssue(issue), comments },
157
+ output: `Retrieved ${issue.identifier}: ${issue.title} (${issue.state?.name ?? 'unknown state'}).`,
158
+ };
159
+ },
160
+ },
161
+ create_issue: {
162
+ definition: {
163
+ description: 'Create a new Linear issue.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ team: { type: 'string', description: 'Team key (e.g. "ENG"), name, or id' },
168
+ title: { type: 'string', description: 'Issue title' },
169
+ description: { type: 'string', description: 'Issue description (markdown)' },
170
+ priority: { type: 'number', description: '0 none, 1 urgent, 2 high, 3 medium, 4 low' },
171
+ assignee: { type: 'string', description: '"me" or a Linear user id' },
172
+ stateId: { type: 'string', description: 'Workflow state id (see list_teams)' },
173
+ },
174
+ required: ['team', 'title'],
175
+ },
176
+ },
177
+ run: async (auth, args) => {
178
+ const { team, title, description, priority, assignee, stateId } = args;
179
+ const teamId = (await resolveTeam(auth, team)).id;
180
+ let assigneeId = assignee;
181
+ if (assignee === 'me')
182
+ assigneeId = (await fetchViewer(auth)).viewer.id;
183
+ const data = await linearRequest(auth, `mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`, { input: { teamId, title, description, priority, assigneeId, stateId } });
184
+ const issue = data.issueCreate.issue;
185
+ return {
186
+ data: formatIssue(issue),
187
+ output: `Created ${issue.identifier}: ${issue.title} — ${issue.url}`,
188
+ };
189
+ },
190
+ },
191
+ update_issue: {
192
+ definition: {
193
+ description: 'Update a Linear issue (title, description, priority, state, assignee).',
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ issue: { type: 'string', description: 'Issue identifier (e.g. "ENG-123") or id' },
198
+ title: { type: 'string', description: 'New title' },
199
+ description: { type: 'string', description: 'New description (markdown)' },
200
+ priority: { type: 'number', description: '0 none, 1 urgent, 2 high, 3 medium, 4 low' },
201
+ assignee: { type: 'string', description: '"me", a Linear user id, or "none" to unassign' },
202
+ stateId: { type: 'string', description: 'Workflow state id (see list_teams)' },
203
+ },
204
+ required: ['issue'],
205
+ },
206
+ },
207
+ run: async (auth, args) => {
208
+ const { issue: issueRef, title, description, priority, assignee, stateId } = args;
209
+ const issueId = await resolveIssueId(auth, issueRef);
210
+ let assigneeId = assignee;
211
+ if (assignee === 'me')
212
+ assigneeId = (await fetchViewer(auth)).viewer.id;
213
+ if (assignee === 'none')
214
+ assigneeId = null;
215
+ 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 } });
216
+ const issue = data.issueUpdate.issue;
217
+ return {
218
+ data: formatIssue(issue),
219
+ output: `Updated ${issue.identifier}: ${issue.title} (${issue.state?.name ?? 'unknown state'}).`,
220
+ };
221
+ },
222
+ },
223
+ create_comment: {
224
+ definition: {
225
+ description: 'Add a comment to a Linear issue.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ issue: { type: 'string', description: 'Issue identifier (e.g. "ENG-123") or id' },
230
+ body: { type: 'string', description: 'Comment body (markdown)' },
231
+ },
232
+ required: ['issue', 'body'],
233
+ },
234
+ },
235
+ run: async (auth, args) => {
236
+ const { issue: issueRef, body } = args;
237
+ const issueId = await resolveIssueId(auth, issueRef);
238
+ await linearRequest(auth, `mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }`, { input: { issueId, body } });
239
+ return {
240
+ data: { success: true },
241
+ output: `Comment added to ${issueRef}.`,
242
+ };
243
+ },
244
+ },
245
+ list_projects: {
246
+ definition: {
247
+ description: 'List Linear projects.',
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: {
251
+ limit: { type: 'number', description: 'Max projects to return (default 25)' },
252
+ },
253
+ },
254
+ },
255
+ run: async (auth, args) => {
256
+ const { limit } = args;
257
+ 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) });
258
+ const projects = data.projects.nodes;
259
+ return {
260
+ data: projects,
261
+ output: `Found ${projects.length} project(s): ${projects.map((p) => p.name).join(', ')}`,
262
+ widget: {
263
+ kind: 'list',
264
+ title: 'Linear Projects',
265
+ items: projects.map((p) => ({
266
+ id: p.id,
267
+ label: p.name,
268
+ description: p.lead?.displayName ? `Lead: ${p.lead.displayName}` : undefined,
269
+ status: p.state,
270
+ metadata: { url: p.url },
271
+ })),
272
+ },
273
+ };
274
+ },
275
+ },
276
+ list_users: {
277
+ definition: {
278
+ description: 'List members of the Linear workspace (useful for resolving assignee ids).',
279
+ inputSchema: { type: 'object', properties: {} },
280
+ },
281
+ run: async (auth) => {
282
+ const data = await linearRequest(auth, `query { users(first: 100) { nodes { id name displayName email active } } }`);
283
+ const users = data.users.nodes.filter((u) => u.active);
284
+ return {
285
+ data: users,
286
+ output: `Found ${users.length} active user(s).`,
287
+ };
288
+ },
289
+ },
290
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@meetopenbot/linear",
3
+ "version": "0.0.1",
4
+ "description": "Linear OAuth connect flow and MCP-backed agent for issues, projects, and comments",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/meetopenbot/plugin-linear.git"
23
+ },
24
+ "keywords": [
25
+ "openbot",
26
+ "plugin",
27
+ "linear",
28
+ "oauth"
29
+ ],
30
+ "dependencies": {
31
+ "@ai-sdk/mcp": "^2.0.14",
32
+ "@ai-sdk/openai": "^4.0.15",
33
+ "@meetopenbot/plugin-sdk": "^0.1.8",
34
+ "ai": "^7.0.29",
35
+ "mcp-server-linear": "^1.6.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^25.9.1",
39
+ "typescript": "^6.0.3"
40
+ }
41
+ }
package/src/config.ts ADDED
@@ -0,0 +1,228 @@
1
+ import type { Storage } from "@meetopenbot/plugin-sdk";
2
+ import { refreshAccessToken, type OAuthTokens } from "./oauth.js";
3
+
4
+ export const VAR_API_KEY = "LINEAR_API_KEY";
5
+ export const VAR_ACCESS_TOKEN = "LINEAR_ACCESS_TOKEN";
6
+ export const VAR_REFRESH_TOKEN = "LINEAR_REFRESH_TOKEN";
7
+ export const VAR_TOKEN_EXPIRES_AT = "LINEAR_TOKEN_EXPIRES_AT";
8
+ export const VAR_CLIENT_ID = "LINEAR_CLIENT_ID";
9
+
10
+ const REFRESH_MARGIN_MS = 5 * 60 * 1000;
11
+
12
+ export type LinearPluginConfig = {
13
+ clientId?: string;
14
+ clientSecret?: string;
15
+ apiKey?: string;
16
+ oauthPort?: number;
17
+ scopes?: string;
18
+ openaiApiKey?: string;
19
+ model?: string;
20
+ /** Public runtime base URL, e.g. https://my-host.com (used for OAuth webhook callback). */
21
+ webhookBaseUrl?: string;
22
+ };
23
+
24
+ export type LinearCredentials = {
25
+ accessToken: string;
26
+ openaiApiKey: string;
27
+ model: string;
28
+ clientId?: string;
29
+ clientSecret?: string;
30
+ };
31
+
32
+ type VariableValue = string | { value: string; secret: boolean } | undefined;
33
+
34
+ function variableValue(
35
+ variables: Record<string, VariableValue>,
36
+ key: string,
37
+ ): string | undefined {
38
+ const entry = variables[key];
39
+ if (typeof entry === "string") return entry || undefined;
40
+ return entry?.value || undefined;
41
+ }
42
+
43
+ export function readLinearConfig(
44
+ config: Record<string, unknown>,
45
+ ): LinearPluginConfig {
46
+ return {
47
+ clientId:
48
+ typeof config.clientId === "string" && config.clientId.trim()
49
+ ? config.clientId.trim()
50
+ : undefined,
51
+ clientSecret:
52
+ typeof config.clientSecret === "string" && config.clientSecret.trim()
53
+ ? config.clientSecret.trim()
54
+ : undefined,
55
+ apiKey:
56
+ typeof config.apiKey === "string" && config.apiKey.trim()
57
+ ? config.apiKey.trim()
58
+ : undefined,
59
+ oauthPort: typeof config.oauthPort === "number" ? config.oauthPort : 4137,
60
+ scopes:
61
+ typeof config.scopes === "string" && config.scopes.trim()
62
+ ? config.scopes.trim()
63
+ : "read,write,issues:create,comments:create",
64
+ openaiApiKey:
65
+ typeof config.openaiApiKey === "string" && config.openaiApiKey.trim()
66
+ ? config.openaiApiKey.trim()
67
+ : undefined,
68
+ model:
69
+ typeof config.model === "string" && config.model.trim()
70
+ ? config.model.trim()
71
+ : undefined,
72
+ webhookBaseUrl:
73
+ typeof config.webhookBaseUrl === "string" && config.webhookBaseUrl.trim()
74
+ ? config.webhookBaseUrl.trim()
75
+ : undefined,
76
+ };
77
+ }
78
+
79
+ export function resolveWebhookBaseUrl(
80
+ config: LinearPluginConfig,
81
+ publicBaseUrl?: string,
82
+ ): string | undefined {
83
+ const explicit = config.webhookBaseUrl?.replace(/\/$/, "");
84
+ if (explicit) return explicit;
85
+ const host = publicBaseUrl?.trim().replace(/\/$/, "");
86
+ if (host) return host;
87
+ return undefined;
88
+ }
89
+
90
+ export async function saveTokens(
91
+ storage: Storage,
92
+ tokens: OAuthTokens,
93
+ ): Promise<void> {
94
+ await storage.createVariable({
95
+ key: VAR_ACCESS_TOKEN,
96
+ value: tokens.accessToken,
97
+ secret: true,
98
+ });
99
+ if (tokens.refreshToken) {
100
+ await storage.createVariable({
101
+ key: VAR_REFRESH_TOKEN,
102
+ value: tokens.refreshToken,
103
+ secret: true,
104
+ });
105
+ }
106
+ if (tokens.expiresAt) {
107
+ await storage.createVariable({
108
+ key: VAR_TOKEN_EXPIRES_AT,
109
+ value: String(tokens.expiresAt),
110
+ secret: false,
111
+ });
112
+ }
113
+ }
114
+
115
+ export async function clearTokens(storage: Storage): Promise<void> {
116
+ for (const key of [VAR_ACCESS_TOKEN, VAR_REFRESH_TOKEN, VAR_TOKEN_EXPIRES_AT]) {
117
+ await storage.deleteVariable({ key }).catch(() => {});
118
+ }
119
+ }
120
+
121
+ export function formatMissingCredentials(
122
+ missing: Array<"accessToken" | "openaiApiKey">,
123
+ ): string {
124
+ const lines = [
125
+ "Linear agent setup is incomplete. Configure the following in plugin config or environment variables:",
126
+ ];
127
+
128
+ if (missing.includes("accessToken")) {
129
+ lines.push(
130
+ "- Connect Linear with `linear_connect`, or set `apiKey` / `LINEAR_API_KEY`",
131
+ );
132
+ }
133
+ if (missing.includes("openaiApiKey")) {
134
+ lines.push(
135
+ "- `openaiApiKey` / `OPENAI_API_KEY` — OpenAI API key for the agent loop",
136
+ );
137
+ }
138
+
139
+ return lines.join("\n");
140
+ }
141
+
142
+ export async function resolveLinearCredentials(
143
+ config: LinearPluginConfig,
144
+ storage: Storage,
145
+ options?: { requireOpenAi?: boolean },
146
+ ): Promise<
147
+ | { ok: true; credentials: LinearCredentials }
148
+ | { ok: false; missing: Array<"accessToken" | "openaiApiKey"> }
149
+ > {
150
+ const requireOpenAi = options?.requireOpenAi ?? true;
151
+ const variables = (await storage.getVariables().catch(() => ({}))) as Record<
152
+ string,
153
+ VariableValue
154
+ >;
155
+
156
+ const resolve = (configKey: keyof LinearPluginConfig, envKey: string) => {
157
+ const fromConfig = config[configKey];
158
+ if (typeof fromConfig === "string" && fromConfig.trim()) {
159
+ return fromConfig.trim();
160
+ }
161
+ if (process.env[envKey]?.trim()) return process.env[envKey]!.trim();
162
+ return variableValue(variables, envKey)?.trim();
163
+ };
164
+
165
+ const openaiApiKey = resolve("openaiApiKey", "OPENAI_API_KEY");
166
+ const model = resolve("model", "OPENAI_MODEL") ?? "gpt-4o-mini";
167
+
168
+ let accessToken =
169
+ config.apiKey ??
170
+ variableValue(variables, VAR_API_KEY) ??
171
+ process.env[VAR_API_KEY] ??
172
+ variableValue(variables, VAR_ACCESS_TOKEN) ??
173
+ process.env[VAR_ACCESS_TOKEN];
174
+
175
+ const clientId =
176
+ config.clientId ??
177
+ variableValue(variables, VAR_CLIENT_ID) ??
178
+ process.env[VAR_CLIENT_ID];
179
+ const clientSecret = config.clientSecret;
180
+
181
+ if (accessToken && !config.apiKey) {
182
+ const expiresAtRaw =
183
+ variableValue(variables, VAR_TOKEN_EXPIRES_AT) ??
184
+ process.env[VAR_TOKEN_EXPIRES_AT];
185
+ const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : undefined;
186
+ const refreshToken =
187
+ variableValue(variables, VAR_REFRESH_TOKEN) ??
188
+ process.env[VAR_REFRESH_TOKEN];
189
+
190
+ const needsRefresh =
191
+ expiresAt !== undefined &&
192
+ Number.isFinite(expiresAt) &&
193
+ Date.now() > expiresAt - REFRESH_MARGIN_MS;
194
+
195
+ if (needsRefresh && refreshToken && clientId) {
196
+ try {
197
+ const tokens = await refreshAccessToken({
198
+ refreshToken,
199
+ clientId,
200
+ clientSecret,
201
+ });
202
+ await saveTokens(storage, tokens);
203
+ accessToken = tokens.accessToken;
204
+ } catch {
205
+ // Fall through; a 401 from Linear will prompt reconnect.
206
+ }
207
+ }
208
+ }
209
+
210
+ const missing: Array<"accessToken" | "openaiApiKey"> = [];
211
+ if (!accessToken) missing.push("accessToken");
212
+ if (requireOpenAi && !openaiApiKey) missing.push("openaiApiKey");
213
+
214
+ if (missing.length > 0) {
215
+ return { ok: false, missing };
216
+ }
217
+
218
+ return {
219
+ ok: true,
220
+ credentials: {
221
+ accessToken: accessToken!,
222
+ openaiApiKey: openaiApiKey ?? "",
223
+ model,
224
+ clientId,
225
+ clientSecret,
226
+ },
227
+ };
228
+ }