@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/README.md +79 -0
- package/dist/api.d.ts +17 -0
- package/dist/api.js +29 -0
- package/dist/auth.d.ts +33 -0
- package/dist/auth.js +91 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +152 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +318 -0
- package/dist/linear-agent.d.ts +14 -0
- package/dist/linear-agent.js +72 -0
- package/dist/linear-issues.d.ts +33 -0
- package/dist/linear-issues.js +136 -0
- package/dist/linear-mcp.d.ts +4 -0
- package/dist/linear-mcp.js +26 -0
- package/dist/oauth-pending.d.ts +13 -0
- package/dist/oauth-pending.js +40 -0
- package/dist/oauth.d.ts +95 -0
- package/dist/oauth.js +372 -0
- package/dist/tools.d.ts +17 -0
- package/dist/tools.js +290 -0
- package/package.json +41 -0
- package/src/config.ts +228 -0
- package/src/index.ts +449 -0
- package/src/linear-agent.ts +107 -0
- package/src/linear-issues.ts +188 -0
- package/src/linear-mcp.ts +33 -0
- package/src/oauth-pending.ts +68 -0
- package/src/oauth.ts +563 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { RenderUIWidgetData } from "@meetopenbot/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export type LinearIssue = {
|
|
4
|
+
id: string;
|
|
5
|
+
identifier: string;
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
state?: { name: string; type: string };
|
|
9
|
+
assignee?: { name: string };
|
|
10
|
+
team?: { name: string; key: string };
|
|
11
|
+
project?: { name: string };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const OPEN_URL_ACTION_ID = "open_url";
|
|
15
|
+
export const ISSUES_LIST_WIDGET_ID = "linear-issues-list";
|
|
16
|
+
|
|
17
|
+
type LinearStateType =
|
|
18
|
+
| "backlog"
|
|
19
|
+
| "unstarted"
|
|
20
|
+
| "started"
|
|
21
|
+
| "completed"
|
|
22
|
+
| "canceled"
|
|
23
|
+
| string;
|
|
24
|
+
|
|
25
|
+
function mapStateStatus(
|
|
26
|
+
stateType?: LinearStateType,
|
|
27
|
+
): "pending" | "in_progress" | "done" | "cancelled" | undefined {
|
|
28
|
+
switch (stateType) {
|
|
29
|
+
case "started":
|
|
30
|
+
return "in_progress";
|
|
31
|
+
case "completed":
|
|
32
|
+
return "done";
|
|
33
|
+
case "canceled":
|
|
34
|
+
return "cancelled";
|
|
35
|
+
case "backlog":
|
|
36
|
+
case "unstarted":
|
|
37
|
+
return "pending";
|
|
38
|
+
default:
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildIssuesListWidget(
|
|
44
|
+
issues: LinearIssue[],
|
|
45
|
+
options?: { title?: string; description?: string },
|
|
46
|
+
): RenderUIWidgetData {
|
|
47
|
+
return {
|
|
48
|
+
kind: "list",
|
|
49
|
+
widgetId: ISSUES_LIST_WIDGET_ID,
|
|
50
|
+
title: options?.title ?? "Linear issues",
|
|
51
|
+
description:
|
|
52
|
+
options?.description ??
|
|
53
|
+
(issues.length === 0
|
|
54
|
+
? "No issues matched this query."
|
|
55
|
+
: `${issues.length} issue${issues.length === 1 ? "" : "s"}`),
|
|
56
|
+
items: issues.map((issue) => {
|
|
57
|
+
const details = [
|
|
58
|
+
issue.state?.name,
|
|
59
|
+
issue.assignee?.name ? `Assignee: ${issue.assignee.name}` : undefined,
|
|
60
|
+
issue.team?.name,
|
|
61
|
+
issue.project?.name,
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" · ");
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
id: issue.id,
|
|
68
|
+
label: `${issue.identifier}: ${issue.title}`,
|
|
69
|
+
description: details || undefined,
|
|
70
|
+
badge: issue.state?.name,
|
|
71
|
+
status: mapStateStatus(issue.state?.type),
|
|
72
|
+
actions: issue.url
|
|
73
|
+
? [
|
|
74
|
+
{
|
|
75
|
+
id: OPEN_URL_ACTION_ID,
|
|
76
|
+
label: "Open",
|
|
77
|
+
variant: "secondary" as const,
|
|
78
|
+
value: { url: issue.url, target: "_blank" },
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
: undefined,
|
|
82
|
+
metadata: {
|
|
83
|
+
identifier: issue.identifier,
|
|
84
|
+
url: issue.url,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractTextPayload(value: unknown): string | undefined {
|
|
92
|
+
if (typeof value === "string") return value;
|
|
93
|
+
if (!value || typeof value !== "object") return undefined;
|
|
94
|
+
|
|
95
|
+
const record = value as Record<string, unknown>;
|
|
96
|
+
if (typeof record.text === "string") return record.text;
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(record.content)) {
|
|
99
|
+
for (const part of record.content) {
|
|
100
|
+
if (
|
|
101
|
+
part &&
|
|
102
|
+
typeof part === "object" &&
|
|
103
|
+
(part as { type?: string }).type === "text" &&
|
|
104
|
+
typeof (part as { text?: string }).text === "string"
|
|
105
|
+
) {
|
|
106
|
+
return (part as { text: string }).text;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseIssuesPayload(payload: unknown): LinearIssue[] {
|
|
115
|
+
if (!payload || typeof payload !== "object") return [];
|
|
116
|
+
|
|
117
|
+
const root = payload as Record<string, unknown>;
|
|
118
|
+
const issuesNode =
|
|
119
|
+
(root.issues as { nodes?: unknown[] } | undefined) ??
|
|
120
|
+
((root.data as Record<string, unknown> | undefined)?.issues as
|
|
121
|
+
| { nodes?: unknown[] }
|
|
122
|
+
| undefined);
|
|
123
|
+
|
|
124
|
+
if (!issuesNode?.nodes || !Array.isArray(issuesNode.nodes)) return [];
|
|
125
|
+
|
|
126
|
+
return issuesNode.nodes
|
|
127
|
+
.filter((node): node is LinearIssue => {
|
|
128
|
+
if (!node || typeof node !== "object") return false;
|
|
129
|
+
const issue = node as LinearIssue;
|
|
130
|
+
return (
|
|
131
|
+
typeof issue.id === "string" &&
|
|
132
|
+
typeof issue.identifier === "string" &&
|
|
133
|
+
typeof issue.title === "string"
|
|
134
|
+
);
|
|
135
|
+
})
|
|
136
|
+
.map((issue) => ({
|
|
137
|
+
...issue,
|
|
138
|
+
url: typeof issue.url === "string" ? issue.url : "",
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function extractIssuesFromToolResults(
|
|
143
|
+
toolResults: Array<{ toolName: string; output: unknown }>,
|
|
144
|
+
): LinearIssue[] {
|
|
145
|
+
const byId = new Map<string, LinearIssue>();
|
|
146
|
+
|
|
147
|
+
for (const toolResult of toolResults) {
|
|
148
|
+
if (!toolResult.toolName.includes("search_issues")) continue;
|
|
149
|
+
|
|
150
|
+
const text = extractTextPayload(toolResult.output);
|
|
151
|
+
if (!text) continue;
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
for (const issue of parseIssuesPayload(JSON.parse(text))) {
|
|
155
|
+
byId.set(issue.id, issue);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore malformed tool payloads.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...byId.values()];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function isListIssuesPrompt(message: string): boolean {
|
|
166
|
+
const normalized = message.trim().toLowerCase();
|
|
167
|
+
if (!normalized) return false;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
/\b(list|show|get|fetch|what are|available)\b/.test(normalized) &&
|
|
171
|
+
/\bissues?\b/.test(normalized)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isAssignedIssuesPrompt(message: string): boolean {
|
|
176
|
+
const normalized = message.trim().toLowerCase();
|
|
177
|
+
return (
|
|
178
|
+
/\b(assigned to me|my issues|issues for me|issues assigned)\b/.test(
|
|
179
|
+
normalized,
|
|
180
|
+
) || /\bissues?\s+assigned\b/.test(normalized)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function issuesListTitle(prompt: string): string {
|
|
185
|
+
return isAssignedIssuesPrompt(prompt)
|
|
186
|
+
? "Issues assigned to you"
|
|
187
|
+
: "Linear issues";
|
|
188
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { createMCPClient } from "@ai-sdk/mcp";
|
|
3
|
+
import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
|
|
4
|
+
|
|
5
|
+
export type LinearMcpClientArgs = {
|
|
6
|
+
accessToken: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
function resolveMcpServerLinearLaunch(): { command: string; args: string[] } {
|
|
12
|
+
try {
|
|
13
|
+
const entry = require.resolve("mcp-server-linear");
|
|
14
|
+
return { command: process.execPath, args: [entry] };
|
|
15
|
+
} catch {
|
|
16
|
+
return { command: "npx", args: ["-y", "mcp-server-linear"] };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function createLinearMcpClient(args: LinearMcpClientArgs) {
|
|
21
|
+
const launch = resolveMcpServerLinearLaunch();
|
|
22
|
+
|
|
23
|
+
return createMCPClient({
|
|
24
|
+
transport: new Experimental_StdioMCPTransport({
|
|
25
|
+
command: launch.command,
|
|
26
|
+
args: launch.args,
|
|
27
|
+
env: {
|
|
28
|
+
...process.env,
|
|
29
|
+
LINEAR_ACCESS_TOKEN: args.accessToken,
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Storage } from "@meetopenbot/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
export const VAR_OAUTH_PENDING = "LINEAR_OAUTH_PENDING";
|
|
4
|
+
|
|
5
|
+
export interface PendingOAuthSession {
|
|
6
|
+
state: string;
|
|
7
|
+
codeVerifier: string;
|
|
8
|
+
clientId: string;
|
|
9
|
+
clientSecret?: string;
|
|
10
|
+
redirectUri: string;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type VariableValue = string | { value: string; secret: boolean } | undefined;
|
|
15
|
+
|
|
16
|
+
function variableValue(
|
|
17
|
+
variables: Record<string, VariableValue>,
|
|
18
|
+
key: string,
|
|
19
|
+
): string | undefined {
|
|
20
|
+
const entry = variables[key];
|
|
21
|
+
if (typeof entry === "string") return entry || undefined;
|
|
22
|
+
return entry?.value || undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function savePendingOAuthSession(
|
|
26
|
+
storage: Storage,
|
|
27
|
+
session: PendingOAuthSession,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await storage.createVariable({
|
|
30
|
+
key: VAR_OAUTH_PENDING,
|
|
31
|
+
value: JSON.stringify(session),
|
|
32
|
+
secret: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function loadPendingOAuthSession(
|
|
37
|
+
storage: Storage,
|
|
38
|
+
): Promise<PendingOAuthSession | null> {
|
|
39
|
+
const variables = (await storage.getVariables().catch(() => ({}))) as Record<
|
|
40
|
+
string,
|
|
41
|
+
VariableValue
|
|
42
|
+
>;
|
|
43
|
+
const raw =
|
|
44
|
+
variableValue(variables, VAR_OAUTH_PENDING) ??
|
|
45
|
+
process.env[VAR_OAUTH_PENDING];
|
|
46
|
+
if (!raw) return null;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const session = JSON.parse(raw) as PendingOAuthSession;
|
|
50
|
+
if (
|
|
51
|
+
typeof session.state !== "string" ||
|
|
52
|
+
typeof session.codeVerifier !== "string" ||
|
|
53
|
+
typeof session.clientId !== "string" ||
|
|
54
|
+
typeof session.redirectUri !== "string" ||
|
|
55
|
+
typeof session.expiresAt !== "number"
|
|
56
|
+
) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() > session.expiresAt) return null;
|
|
60
|
+
return session;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function clearPendingOAuthSession(storage: Storage): Promise<void> {
|
|
67
|
+
await storage.deleteVariable({ key: VAR_OAUTH_PENDING }).catch(() => {});
|
|
68
|
+
}
|