@yannelli/paseo-linear-plugin 0.1.0 → 0.4.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.
Files changed (51) hide show
  1. package/NOTICE +8 -4
  2. package/OVERVIEW.md +23 -13
  3. package/README.md +138 -29
  4. package/client/agent-options.ts +200 -0
  5. package/client/autosave.ts +77 -0
  6. package/client/breadcrumbs.tsx +110 -0
  7. package/client/browser.tsx +275 -0
  8. package/client/compat.ts +107 -0
  9. package/client/connect.tsx +136 -0
  10. package/client/create-issue.tsx +291 -0
  11. package/client/glyphs.tsx +117 -0
  12. package/client/issue-description.tsx +206 -0
  13. package/client/issue-detail.tsx +358 -0
  14. package/client/issue-list.tsx +373 -0
  15. package/client/issue-row.tsx +152 -0
  16. package/client/issue-sections.tsx +325 -0
  17. package/client/issue-tree.ts +132 -0
  18. package/client/key-scope.tsx +23 -0
  19. package/client/launch-choices.ts +125 -0
  20. package/client/launch-fields.tsx +152 -0
  21. package/client/launch-plan.ts +227 -0
  22. package/client/launch.tsx +484 -0
  23. package/client/markdown.tsx +302 -0
  24. package/client/model-browser.tsx +296 -0
  25. package/client/pickers.tsx +238 -0
  26. package/client/provider-icon.tsx +78 -0
  27. package/client/queries.ts +242 -0
  28. package/client/settings-connection.tsx +109 -0
  29. package/client/settings-fields.tsx +173 -0
  30. package/client/settings-keys.tsx +117 -0
  31. package/client/settings-projects.tsx +281 -0
  32. package/client/settings-prompts.tsx +198 -0
  33. package/client/store.ts +71 -0
  34. package/client/timeline-card.tsx +63 -0
  35. package/client/ui.tsx +352 -0
  36. package/index.client.tsx +111 -0
  37. package/index.server.ts +7 -3
  38. package/package.json +4 -3
  39. package/paseo-plugin.json +2 -2
  40. package/server/cache.ts +68 -0
  41. package/server/credentials.ts +101 -0
  42. package/server/graphql.ts +68 -0
  43. package/server/handlers.ts +172 -0
  44. package/server/linear.ts +11 -64
  45. package/server/queries.ts +280 -0
  46. package/shared/linear.ts +268 -0
  47. package/shared/markdown.ts +461 -0
  48. package/shared/prompts.ts +150 -0
  49. package/shared/settings.ts +68 -0
  50. package/index.client.ts +0 -7
  51. package/server/issues.ts +0 -10
@@ -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 { searchIssues } from "./server/issues";
3
- import { searchIssuesRpc } from "./shared/issues";
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.handle(searchIssuesRpc, searchIssues);
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.1.0",
4
- "description": "Attach Linear issues to Paseo agent prompts",
3
+ "version": "0.4.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.ts",
27
+ "index.client.tsx",
28
28
  "index.server.ts",
29
+ "client/",
29
30
  "server/",
30
31
  "shared/"
31
32
  ],
package/paseo-plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "linear",
3
- "description": "Attach Linear issues to agent prompts",
3
+ "description": "Browse, triage, and create Linear issues, and start agents that implement or review them",
4
4
  "requirements": {
5
- "paseo": ">=0.9.0"
5
+ "paseo": ">=0.10.0"
6
6
  }
7
7
  }
@@ -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 LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough();
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
- data: z
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
- interface LinearIssueSearchOptions {
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 apiKey = options.apiKey.trim();
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({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }),
107
+ await graphql(EXACT_ISSUE_QUERY, { id: identifier }),
158
108
  );
159
- throwGraphqlErrors(response.errors);
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({ query: SEARCH_ISSUES_QUERY, variables: { filter } }),
115
+ await graphql(SEARCH_ISSUES_QUERY, { filter }),
167
116
  );
168
- throwGraphqlErrors(response.errors);
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 {