@yannelli/paseo-linear-plugin 0.1.0 → 0.3.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/NOTICE +8 -4
- package/OVERVIEW.md +22 -13
- package/README.md +134 -29
- package/client/agent-options.ts +200 -0
- package/client/breadcrumbs.tsx +110 -0
- package/client/browser.tsx +275 -0
- package/client/compat.ts +107 -0
- package/client/connect.tsx +136 -0
- package/client/create-issue.tsx +291 -0
- package/client/glyphs.tsx +117 -0
- package/client/issue-detail.tsx +366 -0
- package/client/issue-list.tsx +373 -0
- package/client/issue-row.tsx +152 -0
- package/client/issue-sections.tsx +325 -0
- package/client/issue-tree.ts +132 -0
- package/client/key-scope.tsx +23 -0
- package/client/launch-choices.ts +125 -0
- package/client/launch-fields.tsx +152 -0
- package/client/launch-plan.ts +227 -0
- package/client/launch.tsx +484 -0
- package/client/markdown.tsx +240 -0
- package/client/model-browser.tsx +296 -0
- package/client/pickers.tsx +238 -0
- package/client/provider-icon.tsx +78 -0
- package/client/queries.ts +211 -0
- package/client/settings-connection.tsx +109 -0
- package/client/settings-fields.tsx +173 -0
- package/client/settings-keys.tsx +117 -0
- package/client/settings-projects.tsx +281 -0
- package/client/settings-prompts.tsx +198 -0
- package/client/store.ts +71 -0
- package/client/timeline-card.tsx +63 -0
- package/client/ui.tsx +352 -0
- package/index.client.tsx +111 -0
- package/index.server.ts +7 -3
- package/package.json +4 -3
- package/paseo-plugin.json +2 -2
- package/server/cache.ts +68 -0
- package/server/credentials.ts +101 -0
- package/server/graphql.ts +68 -0
- package/server/handlers.ts +172 -0
- package/server/linear.ts +11 -64
- package/server/queries.ts +280 -0
- package/shared/linear.ts +268 -0
- package/shared/markdown.ts +416 -0
- package/shared/prompts.ts +150 -0
- package/shared/settings.ts +68 -0
- package/index.client.ts +0 -7
- package/server/issues.ts +0 -10
package/index.client.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import { LinearPanel, LinearScreen } from "./client/browser";
|
|
3
|
+
import { openPluginScreen, registerScreen } from "./client/compat";
|
|
4
|
+
import { ConnectionSettings } from "./client/settings-connection";
|
|
5
|
+
import { ProjectSettings } from "./client/settings-projects";
|
|
6
|
+
import { PromptSettings } from "./client/settings-prompts";
|
|
7
|
+
import { focusIssue, panelScope, SCREEN_SCOPE, updateBrowser } from "./client/store";
|
|
8
|
+
import { IssueCardRow } from "./client/timeline-card";
|
|
9
|
+
import { issueAttachments } from "./shared/issues";
|
|
10
|
+
import { ISSUE_CARD_KIND, IssueCardSchema, LINEAR_IDENTIFIER } from "./shared/linear";
|
|
11
|
+
|
|
12
|
+
const SCREEN_ID = "linear";
|
|
13
|
+
const PANEL_ID = "issues";
|
|
14
|
+
|
|
15
|
+
export default function contribute(client: PluginClientContext) {
|
|
16
|
+
client.addAttachmentSource(issueAttachments);
|
|
17
|
+
registerScreen(client, {
|
|
18
|
+
id: SCREEN_ID,
|
|
19
|
+
title: "Linear",
|
|
20
|
+
icon: "SquareKanban",
|
|
21
|
+
Component: LinearScreen,
|
|
22
|
+
});
|
|
23
|
+
client.addWorkspacePanel({
|
|
24
|
+
id: PANEL_ID,
|
|
25
|
+
title: "Linear",
|
|
26
|
+
icon: "SquareKanban",
|
|
27
|
+
context: "workspace",
|
|
28
|
+
locations: ["workspace", "explorer"],
|
|
29
|
+
Component: LinearPanel,
|
|
30
|
+
});
|
|
31
|
+
client.addSettingsScreen({
|
|
32
|
+
id: "connection",
|
|
33
|
+
title: "Account",
|
|
34
|
+
icon: "KeyRound",
|
|
35
|
+
Component: ConnectionSettings,
|
|
36
|
+
});
|
|
37
|
+
client.addSettingsScreen({
|
|
38
|
+
id: "prompts",
|
|
39
|
+
title: "Prompts",
|
|
40
|
+
icon: "MessageSquare",
|
|
41
|
+
Component: PromptSettings,
|
|
42
|
+
});
|
|
43
|
+
client.addSettingsScreen({
|
|
44
|
+
id: "projects",
|
|
45
|
+
title: "Projects",
|
|
46
|
+
icon: "FolderGit2",
|
|
47
|
+
Component: ProjectSettings,
|
|
48
|
+
});
|
|
49
|
+
client.addCommandCenterItem({
|
|
50
|
+
id: "open-issues",
|
|
51
|
+
title: "Linear: Open issues",
|
|
52
|
+
icon: "SquareKanban",
|
|
53
|
+
keywords: ["linear", "issues", "tickets"],
|
|
54
|
+
context: "global",
|
|
55
|
+
onSelect(context) {
|
|
56
|
+
openPluginScreen(context, SCREEN_ID);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
client.addCommandCenterItem({
|
|
60
|
+
id: "create-issue",
|
|
61
|
+
title: "Linear: Create issue",
|
|
62
|
+
icon: "Plus",
|
|
63
|
+
keywords: ["linear", "new", "ticket"],
|
|
64
|
+
context: "global",
|
|
65
|
+
onSelect(context) {
|
|
66
|
+
updateBrowser(SCREEN_SCOPE, { creating: true });
|
|
67
|
+
openPluginScreen(context, SCREEN_ID);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
client.addCommandCenterItem({
|
|
71
|
+
id: "workspace-issues",
|
|
72
|
+
title: "Linear: Show issues in this workspace",
|
|
73
|
+
icon: "SquareKanban",
|
|
74
|
+
keywords: ["linear", "panel"],
|
|
75
|
+
context: "workspace",
|
|
76
|
+
onSelect({ openPanel }) {
|
|
77
|
+
openPanel(PANEL_ID);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
client.addCommandCenterItem({
|
|
81
|
+
id: "settings",
|
|
82
|
+
title: "Linear: Settings",
|
|
83
|
+
icon: "Settings",
|
|
84
|
+
keywords: ["linear", "api key", "prompts", "projects"],
|
|
85
|
+
context: "global",
|
|
86
|
+
onSelect({ openSettings }) {
|
|
87
|
+
openSettings("connection");
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
client.addSlashCommand({
|
|
91
|
+
name: "linear",
|
|
92
|
+
description: "Open Linear issues, or jump to one by key",
|
|
93
|
+
argumentHint: "[ENG-123 or search]",
|
|
94
|
+
context: "workspace",
|
|
95
|
+
onSubmit({ args, workspace, openPanel }) {
|
|
96
|
+
const scope = panelScope(workspace.id);
|
|
97
|
+
const text = args.trim();
|
|
98
|
+
if (LINEAR_IDENTIFIER.test(text)) focusIssue(scope, text.toUpperCase());
|
|
99
|
+
else if (text)
|
|
100
|
+
updateBrowser(scope, { query: text, issueId: null, assignee: "anyone", status: "all" });
|
|
101
|
+
openPanel(PANEL_ID);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
client.addTimelineRenderer({
|
|
105
|
+
kind: ISSUE_CARD_KIND,
|
|
106
|
+
version: 1,
|
|
107
|
+
schema: IssueCardSchema,
|
|
108
|
+
Component: IssueCardRow,
|
|
109
|
+
});
|
|
110
|
+
return () => {};
|
|
111
|
+
}
|
package/index.server.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createCredentialStore, defaultCredentialPath } from "./server/credentials";
|
|
3
|
+
import { registerHandlers } from "./server/handlers";
|
|
4
|
+
import { linearSettings } from "./shared/settings";
|
|
4
5
|
|
|
5
6
|
export default function contribute(server: PluginServerContext) {
|
|
6
|
-
server.
|
|
7
|
+
server.registerSettings(linearSettings);
|
|
8
|
+
registerHandlers(server, {
|
|
9
|
+
credentials: createCredentialStore({ file: defaultCredentialPath() }),
|
|
10
|
+
});
|
|
7
11
|
return () => {};
|
|
8
12
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yannelli/paseo-linear-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Browse and triage Linear issues in Paseo and start agents from them",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
"paseo-plugin.json",
|
|
25
25
|
"OVERVIEW.md",
|
|
26
26
|
"NOTICE",
|
|
27
|
-
"index.client.
|
|
27
|
+
"index.client.tsx",
|
|
28
28
|
"index.server.ts",
|
|
29
|
+
"client/",
|
|
29
30
|
"server/",
|
|
30
31
|
"shared/"
|
|
31
32
|
],
|
package/paseo-plugin.json
CHANGED
package/server/cache.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface CachedValue<T> {
|
|
4
|
+
value: T;
|
|
5
|
+
fetchedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ResponseCache {
|
|
9
|
+
get<T>(key: string): CachedValue<T> | null;
|
|
10
|
+
set(key: string, value: unknown): void;
|
|
11
|
+
/** Drops every entry whose key starts with `prefix`. */
|
|
12
|
+
drop(prefix: string): void;
|
|
13
|
+
clear(): void;
|
|
14
|
+
size(): number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Entries are partitioned by a hash of the API key, so two Linear workspaces never share data.
|
|
18
|
+
export function keyFingerprint(apiKey: string): string {
|
|
19
|
+
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cacheKey(fingerprint: string, kind: string, params: unknown = null): string {
|
|
23
|
+
return `${fingerprint}:${kind}:${JSON.stringify(params)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createResponseCache(options: {
|
|
27
|
+
maxEntries: number;
|
|
28
|
+
maxAgeMs: number;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}): ResponseCache {
|
|
31
|
+
const now = options.now ?? Date.now;
|
|
32
|
+
const entries = new Map<string, CachedValue<unknown>>();
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
get<T>(key: string) {
|
|
36
|
+
const entry = entries.get(key);
|
|
37
|
+
if (!entry) return null;
|
|
38
|
+
if (now() - entry.fetchedAt > options.maxAgeMs) {
|
|
39
|
+
entries.delete(key);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
// Map order is insertion order; re-inserting marks the entry as recently used.
|
|
43
|
+
entries.delete(key);
|
|
44
|
+
entries.set(key, entry);
|
|
45
|
+
return entry as CachedValue<T>;
|
|
46
|
+
},
|
|
47
|
+
set(key, value) {
|
|
48
|
+
entries.delete(key);
|
|
49
|
+
entries.set(key, { value, fetchedAt: now() });
|
|
50
|
+
while (entries.size > options.maxEntries) {
|
|
51
|
+
const oldest = entries.keys().next().value;
|
|
52
|
+
if (oldest === undefined) break;
|
|
53
|
+
entries.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
drop(prefix) {
|
|
57
|
+
for (const key of [...entries.keys()]) {
|
|
58
|
+
if (key.startsWith(prefix)) entries.delete(key);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
clear() {
|
|
62
|
+
entries.clear();
|
|
63
|
+
},
|
|
64
|
+
size() {
|
|
65
|
+
return entries.size;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { KeySource } from "../shared/linear";
|
|
6
|
+
|
|
7
|
+
// Version 1 files hold only `apiKey`. Version 2 adds keys for individual Paseo projects.
|
|
8
|
+
const CredentialFileSchema = z.object({
|
|
9
|
+
apiKey: z.string().min(1).optional(),
|
|
10
|
+
projects: z.record(z.string(), z.object({ apiKey: z.string().min(1) })).default({}),
|
|
11
|
+
});
|
|
12
|
+
type CredentialFile = z.infer<typeof CredentialFileSchema>;
|
|
13
|
+
|
|
14
|
+
export interface ResolvedCredential {
|
|
15
|
+
apiKey: string;
|
|
16
|
+
source: KeySource;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CredentialStore {
|
|
20
|
+
/** Project key, then the saved default key, then LINEAR_API_KEY. */
|
|
21
|
+
resolve(projectId?: string | null): Promise<ResolvedCredential | null>;
|
|
22
|
+
save(apiKey: string, projectId?: string | null): Promise<void>;
|
|
23
|
+
clear(projectId?: string | null): Promise<void>;
|
|
24
|
+
projectKeys(): Promise<{ projectId: string; apiKey: string }[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function defaultCredentialPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
28
|
+
const home = env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
|
|
29
|
+
return path.join(home, "plugin-data", "linear", "credentials.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createCredentialStore(options: {
|
|
33
|
+
file: string;
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
}): CredentialStore {
|
|
36
|
+
const env = options.env ?? process.env;
|
|
37
|
+
|
|
38
|
+
async function read(): Promise<CredentialFile> {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = CredentialFileSchema.safeParse(
|
|
41
|
+
JSON.parse(await readFile(options.file, "utf8")),
|
|
42
|
+
);
|
|
43
|
+
return parsed.success ? parsed.data : { projects: {} };
|
|
44
|
+
} catch {
|
|
45
|
+
return { projects: {} };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function write(next: CredentialFile): Promise<void> {
|
|
50
|
+
if (!next.apiKey && Object.keys(next.projects).length === 0) {
|
|
51
|
+
await rm(options.file, { force: true });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await mkdir(path.dirname(options.file), { recursive: true, mode: 0o700 });
|
|
55
|
+
const temporary = `${options.file}.${process.pid}.tmp`;
|
|
56
|
+
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
57
|
+
await chmod(temporary, 0o600);
|
|
58
|
+
await rename(temporary, options.file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
async resolve(projectId) {
|
|
63
|
+
const stored = await read();
|
|
64
|
+
const projectKey = projectId ? stored.projects[projectId]?.apiKey : undefined;
|
|
65
|
+
if (projectKey) return { apiKey: projectKey, source: "project" };
|
|
66
|
+
if (stored.apiKey) return { apiKey: stored.apiKey, source: "file" };
|
|
67
|
+
const fromEnv = env.LINEAR_API_KEY?.trim();
|
|
68
|
+
return fromEnv ? { apiKey: fromEnv, source: "environment" } : null;
|
|
69
|
+
},
|
|
70
|
+
async save(apiKey, projectId) {
|
|
71
|
+
const stored = await read();
|
|
72
|
+
const key = apiKey.trim();
|
|
73
|
+
if (projectId) {
|
|
74
|
+
await write({ ...stored, projects: { ...stored.projects, [projectId]: { apiKey: key } } });
|
|
75
|
+
} else {
|
|
76
|
+
await write({ ...stored, apiKey: key });
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async clear(projectId) {
|
|
80
|
+
const stored = await read();
|
|
81
|
+
if (projectId) {
|
|
82
|
+
const projects = { ...stored.projects };
|
|
83
|
+
delete projects[projectId];
|
|
84
|
+
await write({ ...stored, projects });
|
|
85
|
+
} else {
|
|
86
|
+
await write({ projects: stored.projects });
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async projectKeys() {
|
|
90
|
+
const stored = await read();
|
|
91
|
+
return Object.entries(stored.projects).map(([projectId, entry]) => ({
|
|
92
|
+
projectId,
|
|
93
|
+
apiKey: entry.apiKey,
|
|
94
|
+
}));
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function keyHint(apiKey: string): string {
|
|
100
|
+
return apiKey.length > 8 ? `…${apiKey.slice(-4)}` : "…";
|
|
101
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const LINEAR_ENDPOINT = "https://api.linear.app/graphql";
|
|
4
|
+
|
|
5
|
+
export class LinearApiError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "LinearApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LinearGraphqlOptions {
|
|
13
|
+
apiKey: string;
|
|
14
|
+
endpoint?: string;
|
|
15
|
+
request?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type LinearGraphql = (
|
|
19
|
+
query: string,
|
|
20
|
+
variables?: Record<string, unknown>,
|
|
21
|
+
) => Promise<unknown>;
|
|
22
|
+
|
|
23
|
+
const GraphqlEnvelopeSchema = z.object({
|
|
24
|
+
data: z.unknown().optional(),
|
|
25
|
+
errors: z.array(z.object({ message: z.string() }).passthrough()).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function describeHttpFailure(status: number): string {
|
|
29
|
+
if (status === 401 || status === 403) return "Linear rejected the API key";
|
|
30
|
+
if (status === 429) return "Linear rate limit reached. Try again shortly";
|
|
31
|
+
return `Linear API request failed with HTTP ${status}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createLinearGraphql(options: LinearGraphqlOptions): LinearGraphql {
|
|
35
|
+
const apiKey = options.apiKey.trim();
|
|
36
|
+
if (!apiKey) throw new LinearApiError("Connect Linear: add an API key in Linear settings");
|
|
37
|
+
const endpoint = options.endpoint ?? LINEAR_ENDPOINT;
|
|
38
|
+
const request = options.request ?? fetch;
|
|
39
|
+
|
|
40
|
+
return async (query, variables = {}) => {
|
|
41
|
+
const response = await request(endpoint, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { Authorization: apiKey, "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({ query, variables }),
|
|
45
|
+
});
|
|
46
|
+
// Linear reports auth and validation failures as HTTP 400 with GraphQL errors.
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
let body: z.infer<typeof GraphqlEnvelopeSchema> | null = null;
|
|
49
|
+
try {
|
|
50
|
+
body = GraphqlEnvelopeSchema.parse(JSON.parse(text));
|
|
51
|
+
} catch {
|
|
52
|
+
body = null;
|
|
53
|
+
}
|
|
54
|
+
if (body?.errors && body.errors.length > 0) {
|
|
55
|
+
const message = body.errors.map((error) => error.message).join("; ");
|
|
56
|
+
throw new LinearApiError(
|
|
57
|
+
response.status === 401 || response.status === 403
|
|
58
|
+
? describeHttpFailure(response.status)
|
|
59
|
+
: message,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
|
|
63
|
+
if (!body || body.data === undefined || body.data === null) {
|
|
64
|
+
throw new LinearApiError("Linear returned no data");
|
|
65
|
+
}
|
|
66
|
+
return body.data;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import {
|
|
3
|
+
attachIssueCardRpc,
|
|
4
|
+
authClearRpc,
|
|
5
|
+
authSaveRpc,
|
|
6
|
+
authStatusRpc,
|
|
7
|
+
cachedCatalogRpc,
|
|
8
|
+
cachedIssueRpc,
|
|
9
|
+
cachedIssuesRpc,
|
|
10
|
+
catalogRpc,
|
|
11
|
+
createCommentRpc,
|
|
12
|
+
createIssueRpc,
|
|
13
|
+
getIssueRpc,
|
|
14
|
+
ISSUE_CARD_KIND,
|
|
15
|
+
type IssueQuery,
|
|
16
|
+
listIssuesRpc,
|
|
17
|
+
updateIssueRpc,
|
|
18
|
+
} from "../shared/linear";
|
|
19
|
+
import { searchIssuesRpc } from "../shared/issues";
|
|
20
|
+
import { cacheKey, createResponseCache, keyFingerprint, type ResponseCache } from "./cache";
|
|
21
|
+
import { type CredentialStore, keyHint } from "./credentials";
|
|
22
|
+
import { createLinearGraphql, LinearApiError } from "./graphql";
|
|
23
|
+
import { createLinearIssueSearch } from "./linear";
|
|
24
|
+
import { createLinearService, type LinearService } from "./queries";
|
|
25
|
+
|
|
26
|
+
export interface HandlerDependencies {
|
|
27
|
+
credentials: CredentialStore;
|
|
28
|
+
endpoint?: string;
|
|
29
|
+
cache?: ResponseCache;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ProjectId = string | null | undefined;
|
|
33
|
+
|
|
34
|
+
// The cache key ignores the key scope and cursor fields that do not change the response.
|
|
35
|
+
function listKey(query: IssueQuery) {
|
|
36
|
+
const { teamId, assignee, status, query: text, sort = "updated", after } = query;
|
|
37
|
+
return { teamId, assignee, status, query: text.trim(), sort, after };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerHandlers(server: PluginServerContext, dependencies: HandlerDependencies) {
|
|
41
|
+
const { credentials, endpoint } = dependencies;
|
|
42
|
+
const cache =
|
|
43
|
+
dependencies.cache ?? createResponseCache({ maxEntries: 300, maxAgeMs: 24 * 60 * 60 * 1000 });
|
|
44
|
+
|
|
45
|
+
async function apiKey(projectId?: ProjectId): Promise<string> {
|
|
46
|
+
const credential = await credentials.resolve(projectId);
|
|
47
|
+
if (!credential) throw new LinearApiError("Connect Linear: add an API key in Linear settings");
|
|
48
|
+
return credential.apiKey;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function connect(projectId: ProjectId) {
|
|
52
|
+
const key = await apiKey(projectId);
|
|
53
|
+
return {
|
|
54
|
+
linear: createLinearService(createLinearGraphql({ apiKey: key, endpoint })),
|
|
55
|
+
fingerprint: keyFingerprint(key),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Returns the cached response for a key scope without calling Linear. */
|
|
60
|
+
async function readCache<T>(projectId: ProjectId, kind: string, params: unknown) {
|
|
61
|
+
const credential = await credentials.resolve(projectId);
|
|
62
|
+
const entry = credential
|
|
63
|
+
? cache.get<T>(cacheKey(keyFingerprint(credential.apiKey), kind, params))
|
|
64
|
+
: null;
|
|
65
|
+
return entry
|
|
66
|
+
? { value: entry.value, fetchedAt: new Date(entry.fetchedAt).toISOString() }
|
|
67
|
+
: { value: null, fetchedAt: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fetchAndStore<T>(
|
|
71
|
+
projectId: ProjectId,
|
|
72
|
+
kind: string,
|
|
73
|
+
params: unknown,
|
|
74
|
+
load: (linear: LinearService) => Promise<T>,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const { linear, fingerprint } = await connect(projectId);
|
|
77
|
+
const value = await load(linear);
|
|
78
|
+
cache.set(cacheKey(fingerprint, kind, params), value);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Edits change list order and issue details, so every list and issue entry for the key goes.
|
|
83
|
+
async function mutate<T>(projectId: ProjectId, run: (linear: LinearService) => Promise<T>) {
|
|
84
|
+
const { linear, fingerprint } = await connect(projectId);
|
|
85
|
+
try {
|
|
86
|
+
return await run(linear);
|
|
87
|
+
} finally {
|
|
88
|
+
cache.drop(`${fingerprint}:issues:`);
|
|
89
|
+
cache.drop(`${fingerprint}:issue:`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
server.handle(authStatusRpc, async ({ projectId }) => {
|
|
94
|
+
const credential = await credentials.resolve(projectId);
|
|
95
|
+
const projectKeys = await credentials.projectKeys();
|
|
96
|
+
return {
|
|
97
|
+
configured: credential !== null,
|
|
98
|
+
source: credential?.source ?? null,
|
|
99
|
+
keyHint: credential ? keyHint(credential.apiKey) : null,
|
|
100
|
+
projectKeys: projectKeys.map((entry) => ({
|
|
101
|
+
projectId: entry.projectId,
|
|
102
|
+
keyHint: keyHint(entry.apiKey),
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.handle(authSaveRpc, async ({ apiKey: candidate, projectId }) => {
|
|
108
|
+
// Validate the key against Linear before persisting it.
|
|
109
|
+
const linear = createLinearService(createLinearGraphql({ apiKey: candidate, endpoint }));
|
|
110
|
+
const catalog = await linear.catalog();
|
|
111
|
+
await credentials.save(candidate, projectId);
|
|
112
|
+
cache.clear();
|
|
113
|
+
return { viewerName: catalog.viewer.displayName, organizationName: catalog.organization.name };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
server.handle(authClearRpc, async ({ projectId }) => {
|
|
117
|
+
await credentials.clear(projectId);
|
|
118
|
+
cache.clear();
|
|
119
|
+
return { configured: (await credentials.resolve(projectId)) !== null };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
server.handle(catalogRpc, async ({ projectId }) =>
|
|
123
|
+
fetchAndStore(projectId, "catalog", null, (linear) => linear.catalog()),
|
|
124
|
+
);
|
|
125
|
+
server.handle(listIssuesRpc, async ({ projectId, ...query }) =>
|
|
126
|
+
fetchAndStore(projectId, "issues", listKey(query), (linear) => linear.listIssues(query)),
|
|
127
|
+
);
|
|
128
|
+
server.handle(getIssueRpc, async ({ id, projectId }) =>
|
|
129
|
+
fetchAndStore(projectId, "issue", id, async (linear) => ({ issue: await linear.getIssue(id) })),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.handle(cachedCatalogRpc, async ({ projectId }) => readCache(projectId, "catalog", null));
|
|
133
|
+
server.handle(cachedIssuesRpc, async ({ projectId, ...query }) =>
|
|
134
|
+
readCache(projectId, "issues", listKey(query)),
|
|
135
|
+
);
|
|
136
|
+
server.handle(cachedIssueRpc, async ({ id, projectId }) => readCache(projectId, "issue", id));
|
|
137
|
+
|
|
138
|
+
server.handle(updateIssueRpc, async ({ id, patch, projectId }) => ({
|
|
139
|
+
issue: await mutate(projectId, (linear) => linear.updateIssue(id, patch)),
|
|
140
|
+
}));
|
|
141
|
+
server.handle(createIssueRpc, async ({ projectId, linearProjectId, ...input }) => ({
|
|
142
|
+
issue: await mutate(projectId, (linear) =>
|
|
143
|
+
linear.createIssue(
|
|
144
|
+
linearProjectId === undefined ? input : { ...input, projectId: linearProjectId },
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
}));
|
|
148
|
+
server.handle(createCommentRpc, async ({ issueId, body, projectId }) => ({
|
|
149
|
+
comment: await mutate(projectId, (linear) => linear.createComment(issueId, body)),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
server.handle(searchIssuesRpc, async ({ query }) =>
|
|
153
|
+
createLinearIssueSearch({ apiKey: await apiKey(), endpoint }).search(query),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Only the plugin's daemon session may append timeline rows, so the client asks the server.
|
|
157
|
+
server.handle(attachIssueCardRpc, async ({ agentId, card }, { paseo }) => {
|
|
158
|
+
try {
|
|
159
|
+
await paseo.agents.ref(agentId).timeline.append({
|
|
160
|
+
type: "plugin",
|
|
161
|
+
id: `linear-issue-${card.identifier}`,
|
|
162
|
+
kind: ISSUE_CARD_KIND,
|
|
163
|
+
version: 1,
|
|
164
|
+
data: card,
|
|
165
|
+
});
|
|
166
|
+
return { attached: true };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error("Linear issue card was not attached", error);
|
|
169
|
+
return { attached: false };
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
package/server/linear.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { createLinearGraphql, type LinearGraphqlOptions } from "./graphql";
|
|
2
3
|
|
|
3
4
|
const LinearIssueSchema = z.object({
|
|
4
5
|
id: z.string(),
|
|
@@ -13,17 +14,9 @@ const LinearIssueSchema = z.object({
|
|
|
13
14
|
labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
-
const ExactIssueResponseSchema = z.object({
|
|
18
|
-
data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(),
|
|
19
|
-
errors: z.array(LinearGraphqlErrorSchema).optional(),
|
|
20
|
-
});
|
|
17
|
+
const ExactIssueResponseSchema = z.object({ issue: LinearIssueSchema.nullable() });
|
|
21
18
|
const IssueSearchResponseSchema = z.object({
|
|
22
|
-
|
|
23
|
-
.object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) })
|
|
24
|
-
.nullable()
|
|
25
|
-
.optional(),
|
|
26
|
-
errors: z.array(LinearGraphqlErrorSchema).optional(),
|
|
19
|
+
issues: z.object({ nodes: z.array(LinearIssueSchema) }),
|
|
27
20
|
});
|
|
28
21
|
|
|
29
22
|
const ISSUE_FIELDS = `
|
|
@@ -39,13 +32,13 @@ const ISSUE_FIELDS = `
|
|
|
39
32
|
labels { nodes { name } }
|
|
40
33
|
`;
|
|
41
34
|
|
|
42
|
-
const EXACT_ISSUE_QUERY = `
|
|
35
|
+
export const EXACT_ISSUE_QUERY = `
|
|
43
36
|
query PaseoLinearIssue($id: String!) {
|
|
44
37
|
issue(id: $id) { ${ISSUE_FIELDS} }
|
|
45
38
|
}
|
|
46
39
|
`;
|
|
47
40
|
|
|
48
|
-
const SEARCH_ISSUES_QUERY = `
|
|
41
|
+
export const SEARCH_ISSUES_QUERY = `
|
|
49
42
|
query PaseoLinearIssues($filter: IssueFilter) {
|
|
50
43
|
issues(first: 20, filter: $filter, orderBy: updatedAt) {
|
|
51
44
|
nodes { ${ISSUE_FIELDS} }
|
|
@@ -69,23 +62,7 @@ export interface LinearIssueSearch {
|
|
|
69
62
|
search(query: string): Promise<{ items: LinearAttachmentItem[] }>;
|
|
70
63
|
}
|
|
71
64
|
|
|
72
|
-
|
|
73
|
-
apiKey: string;
|
|
74
|
-
endpoint?: string;
|
|
75
|
-
request?: typeof fetch;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface GraphqlRequest {
|
|
79
|
-
query: string;
|
|
80
|
-
variables: Record<string, unknown>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
class LinearApiError extends Error {
|
|
84
|
-
constructor(message: string) {
|
|
85
|
-
super(message);
|
|
86
|
-
this.name = "LinearApiError";
|
|
87
|
-
}
|
|
88
|
-
}
|
|
65
|
+
type LinearIssueSearchOptions = LinearGraphqlOptions;
|
|
89
66
|
|
|
90
67
|
function issueSubtitle(issue: z.infer<typeof LinearIssueSchema>): string | undefined {
|
|
91
68
|
const parts = [issue.state.name, issue.assignee?.name].filter(
|
|
@@ -122,52 +99,22 @@ function toAttachmentItem(issue: z.infer<typeof LinearIssueSchema>): LinearAttac
|
|
|
122
99
|
};
|
|
123
100
|
}
|
|
124
101
|
|
|
125
|
-
function describeHttpFailure(status: number): string {
|
|
126
|
-
if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY";
|
|
127
|
-
if (status === 429) return "Linear rate limit reached. Try again shortly";
|
|
128
|
-
return `Linear API request failed with HTTP ${status}`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void {
|
|
132
|
-
if (!errors || errors.length === 0) return;
|
|
133
|
-
throw new LinearApiError(errors.map((error) => error.message).join("; "));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
102
|
export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch {
|
|
137
|
-
const
|
|
138
|
-
if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment");
|
|
139
|
-
const endpoint = options.endpoint ?? "https://api.linear.app/graphql";
|
|
140
|
-
const request = options.request ?? fetch;
|
|
141
|
-
|
|
142
|
-
async function graphql(body: GraphqlRequest): Promise<unknown> {
|
|
143
|
-
const response = await request(endpoint, {
|
|
144
|
-
method: "POST",
|
|
145
|
-
headers: {
|
|
146
|
-
Authorization: apiKey,
|
|
147
|
-
"Content-Type": "application/json",
|
|
148
|
-
},
|
|
149
|
-
body: JSON.stringify(body),
|
|
150
|
-
});
|
|
151
|
-
if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
|
|
152
|
-
return response.json();
|
|
153
|
-
}
|
|
103
|
+
const graphql = createLinearGraphql(options);
|
|
154
104
|
|
|
155
105
|
async function findExact(identifier: string): Promise<LinearAttachmentItem[]> {
|
|
156
106
|
const response = ExactIssueResponseSchema.parse(
|
|
157
|
-
await graphql(
|
|
107
|
+
await graphql(EXACT_ISSUE_QUERY, { id: identifier }),
|
|
158
108
|
);
|
|
159
|
-
|
|
160
|
-
return response.data?.issue ? [toAttachmentItem(response.data.issue)] : [];
|
|
109
|
+
return response.issue ? [toAttachmentItem(response.issue)] : [];
|
|
161
110
|
}
|
|
162
111
|
|
|
163
112
|
async function searchTitles(query: string): Promise<LinearAttachmentItem[]> {
|
|
164
113
|
const filter = query ? { title: { containsIgnoreCase: query } } : null;
|
|
165
114
|
const response = IssueSearchResponseSchema.parse(
|
|
166
|
-
await graphql(
|
|
115
|
+
await graphql(SEARCH_ISSUES_QUERY, { filter }),
|
|
167
116
|
);
|
|
168
|
-
|
|
169
|
-
if (!response.data) throw new LinearApiError("Linear returned no issue data");
|
|
170
|
-
return response.data.issues.nodes.map(toAttachmentItem);
|
|
117
|
+
return response.issues.nodes.map(toAttachmentItem);
|
|
171
118
|
}
|
|
172
119
|
|
|
173
120
|
return {
|