@subsimplicity/pi-extension-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 +109 -0
- package/package.json +49 -0
- package/src/autocomplete.ts +140 -0
- package/src/client.ts +571 -0
- package/src/commands.ts +140 -0
- package/src/events.ts +45 -0
- package/src/format.ts +155 -0
- package/src/index.ts +16 -0
- package/src/results.ts +10 -0
- package/src/schemas.ts +256 -0
- package/src/status.ts +51 -0
- package/src/tools.ts +202 -0
- package/src/types.ts +114 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/* eslint-disable n/no-unsupported-features/node-builtins */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
LinearComment,
|
|
5
|
+
LinearCreateIssueParams,
|
|
6
|
+
LinearIssue,
|
|
7
|
+
LinearIssueSummary,
|
|
8
|
+
LinearSearchParams,
|
|
9
|
+
LinearState,
|
|
10
|
+
LinearTeam,
|
|
11
|
+
LinearUpdateIssueParams,
|
|
12
|
+
LinearViewer,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
type JsonValue =
|
|
16
|
+
| JsonValue[]
|
|
17
|
+
| boolean
|
|
18
|
+
| number
|
|
19
|
+
| string
|
|
20
|
+
| { [key: string]: JsonValue | undefined }
|
|
21
|
+
| null;
|
|
22
|
+
|
|
23
|
+
type JsonObject = Record<string, JsonValue | undefined>;
|
|
24
|
+
|
|
25
|
+
interface GraphQLResponse<T> {
|
|
26
|
+
data?: T;
|
|
27
|
+
errors?: {
|
|
28
|
+
extensions?: Record<string, unknown>;
|
|
29
|
+
message: string;
|
|
30
|
+
path?: unknown[];
|
|
31
|
+
}[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class LinearApiError extends Error {
|
|
35
|
+
public constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
public readonly details?: unknown,
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "LinearApiError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
|
|
45
|
+
const LINEAR_PAGE_SIZE = 100;
|
|
46
|
+
const ISSUE_KEY_PATTERN = /\b[A-Z][A-Z0-9]+-\d+\b/g;
|
|
47
|
+
|
|
48
|
+
const ISSUE_SUMMARY_FIELDS = `
|
|
49
|
+
id
|
|
50
|
+
identifier
|
|
51
|
+
title
|
|
52
|
+
description
|
|
53
|
+
url
|
|
54
|
+
priority
|
|
55
|
+
priorityLabel
|
|
56
|
+
estimate
|
|
57
|
+
dueDate
|
|
58
|
+
createdAt
|
|
59
|
+
updatedAt
|
|
60
|
+
state { id name type position }
|
|
61
|
+
team { id key name }
|
|
62
|
+
assignee { id name displayName email }
|
|
63
|
+
delegate { id name displayName email }
|
|
64
|
+
project { id name url }
|
|
65
|
+
labels { nodes { id name color } }
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
function getAuthorizationHeader(): string | undefined {
|
|
69
|
+
const accessToken = process.env.LINEAR_ACCESS_TOKEN?.trim();
|
|
70
|
+
if (accessToken) return `Bearer ${accessToken}`;
|
|
71
|
+
|
|
72
|
+
const apiKey = process.env.LINEAR_API_KEY?.trim();
|
|
73
|
+
if (apiKey) return apiKey;
|
|
74
|
+
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clampLimit(
|
|
79
|
+
value: number | undefined,
|
|
80
|
+
fallback: number,
|
|
81
|
+
max = 50,
|
|
82
|
+
): number {
|
|
83
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
84
|
+
return Math.max(1, Math.min(max, Math.floor(value)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clampCount(
|
|
88
|
+
value: number | undefined,
|
|
89
|
+
fallback: number,
|
|
90
|
+
max = 50,
|
|
91
|
+
): number {
|
|
92
|
+
if (value === undefined || !Number.isFinite(value)) return fallback;
|
|
93
|
+
return Math.max(0, Math.min(max, Math.floor(value)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
97
|
+
const trimmed = value?.trim();
|
|
98
|
+
if (!trimmed) return undefined;
|
|
99
|
+
return trimmed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compactObject<T extends JsonObject>(input: T): T {
|
|
103
|
+
const output: JsonObject = {};
|
|
104
|
+
for (const [key, value] of Object.entries(input)) {
|
|
105
|
+
if (value !== undefined) output[key] = value;
|
|
106
|
+
}
|
|
107
|
+
return output as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function extractIssueKeys(text: string): string[] {
|
|
111
|
+
return [...new Set(text.match(ISSUE_KEY_PATTERN) ?? [])];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function hasLinearCredentials(): boolean {
|
|
115
|
+
return getAuthorizationHeader() !== undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class LinearClient {
|
|
119
|
+
public async graphql<T>(
|
|
120
|
+
query: string,
|
|
121
|
+
variables: JsonObject = {},
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
): Promise<T> {
|
|
124
|
+
const authorization = getAuthorizationHeader();
|
|
125
|
+
if (!authorization) {
|
|
126
|
+
throw new LinearApiError(
|
|
127
|
+
"Linear credentials not configured. Set LINEAR_API_KEY for personal API-key auth or LINEAR_ACCESS_TOKEN for OAuth bearer-token auth.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
|
|
132
|
+
body: JSON.stringify({ query, variables }),
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: authorization,
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
},
|
|
137
|
+
method: "POST",
|
|
138
|
+
signal,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const text = await response.text();
|
|
142
|
+
let payload: GraphQLResponse<T>;
|
|
143
|
+
try {
|
|
144
|
+
payload = JSON.parse(text) as GraphQLResponse<T>;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
throw new LinearApiError(
|
|
147
|
+
`Linear API returned non-JSON HTTP ${String(response.status)}: ${text.slice(0, 500)}`,
|
|
148
|
+
error,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
const message = payload.errors?.map((item) => item.message).join("; ");
|
|
154
|
+
throw new LinearApiError(
|
|
155
|
+
`Linear API HTTP ${String(response.status)}${formatOptionalMessage(message)}`,
|
|
156
|
+
{
|
|
157
|
+
errors: payload.errors,
|
|
158
|
+
rateLimit: readRateLimitHeaders(response.headers),
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (payload.errors?.length) {
|
|
164
|
+
throw new LinearApiError(
|
|
165
|
+
payload.errors.map((item) => item.message).join("; "),
|
|
166
|
+
{
|
|
167
|
+
errors: payload.errors,
|
|
168
|
+
rateLimit: readRateLimitHeaders(response.headers),
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!payload.data) {
|
|
174
|
+
throw new LinearApiError("Linear API response did not include data.", {
|
|
175
|
+
rateLimit: readRateLimitHeaders(response.headers),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return payload.data;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
public async viewer(signal?: AbortSignal): Promise<LinearViewer> {
|
|
183
|
+
const data = await this.graphql<{ viewer: LinearViewer }>(
|
|
184
|
+
`query LinearViewer { viewer { id name displayName email } }`,
|
|
185
|
+
{},
|
|
186
|
+
signal,
|
|
187
|
+
);
|
|
188
|
+
return data.viewer;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
public async teams(signal?: AbortSignal): Promise<LinearTeam[]> {
|
|
192
|
+
const teams: LinearTeam[] = [];
|
|
193
|
+
let cursor: string | undefined;
|
|
194
|
+
let hasNextPage = true;
|
|
195
|
+
|
|
196
|
+
while (hasNextPage) {
|
|
197
|
+
const data = await this.graphql<{
|
|
198
|
+
teams: {
|
|
199
|
+
nodes: LinearTeam[];
|
|
200
|
+
pageInfo: { endCursor: string | null; hasNextPage: boolean };
|
|
201
|
+
};
|
|
202
|
+
}>(
|
|
203
|
+
`query LinearTeams($after: String) {
|
|
204
|
+
teams(first: ${String(LINEAR_PAGE_SIZE)}, after: $after) {
|
|
205
|
+
nodes { id key name }
|
|
206
|
+
pageInfo { hasNextPage endCursor }
|
|
207
|
+
}
|
|
208
|
+
}`,
|
|
209
|
+
compactObject({ after: cursor }),
|
|
210
|
+
signal,
|
|
211
|
+
);
|
|
212
|
+
teams.push(...data.teams.nodes);
|
|
213
|
+
|
|
214
|
+
hasNextPage = data.teams.pageInfo.hasNextPage;
|
|
215
|
+
if (hasNextPage) {
|
|
216
|
+
if (!data.teams.pageInfo.endCursor) {
|
|
217
|
+
throw new LinearApiError(
|
|
218
|
+
"Linear teams pagination indicated another page but did not return an end cursor.",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
cursor = data.teams.pageInfo.endCursor;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return teams;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public async resolveTeamId(
|
|
229
|
+
team: string,
|
|
230
|
+
signal?: AbortSignal,
|
|
231
|
+
): Promise<string> {
|
|
232
|
+
const trimmedTeam = team.trim();
|
|
233
|
+
if (isUuidLike(trimmedTeam)) return trimmedTeam;
|
|
234
|
+
|
|
235
|
+
const needle = trimmedTeam.toLowerCase();
|
|
236
|
+
const teams = await this.teams(signal);
|
|
237
|
+
const match = teams.find(
|
|
238
|
+
(item) =>
|
|
239
|
+
item.id.toLowerCase() === needle ||
|
|
240
|
+
item.key.toLowerCase() === needle ||
|
|
241
|
+
item.name.toLowerCase() === needle,
|
|
242
|
+
);
|
|
243
|
+
if (!match) {
|
|
244
|
+
throw new LinearApiError(
|
|
245
|
+
`Could not resolve Linear team '${team}'. Use linear_list_teams to inspect available team ids and keys.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return match.id;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
public async issue(
|
|
252
|
+
issueId: string,
|
|
253
|
+
commentsLimit = 10,
|
|
254
|
+
signal?: AbortSignal,
|
|
255
|
+
): Promise<LinearIssue> {
|
|
256
|
+
const cappedCommentsLimit = clampCount(commentsLimit, 10);
|
|
257
|
+
const commentsFragment =
|
|
258
|
+
cappedCommentsLimit > 0
|
|
259
|
+
? `comments(first: $commentsLimit) {
|
|
260
|
+
nodes { id body createdAt updatedAt user { id name displayName email } }
|
|
261
|
+
}`
|
|
262
|
+
: "";
|
|
263
|
+
const variables = compactObject({
|
|
264
|
+
commentsLimit: cappedCommentsLimit > 0 ? cappedCommentsLimit : undefined,
|
|
265
|
+
id: issueId,
|
|
266
|
+
});
|
|
267
|
+
const data = await this.graphql<{ issue: LinearIssue | null }>(
|
|
268
|
+
`query LinearIssue($id: String!${cappedCommentsLimit > 0 ? ", $commentsLimit: Int!" : ""}) {
|
|
269
|
+
issue(id: $id) {
|
|
270
|
+
${ISSUE_SUMMARY_FIELDS}
|
|
271
|
+
creator { id name displayName email }
|
|
272
|
+
${commentsFragment}
|
|
273
|
+
}
|
|
274
|
+
}`,
|
|
275
|
+
variables,
|
|
276
|
+
signal,
|
|
277
|
+
);
|
|
278
|
+
if (!data.issue) {
|
|
279
|
+
throw new LinearApiError(`Linear issue not found: ${issueId}`);
|
|
280
|
+
}
|
|
281
|
+
return data.issue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public async searchIssues(
|
|
285
|
+
params: LinearSearchParams,
|
|
286
|
+
signal?: AbortSignal,
|
|
287
|
+
): Promise<LinearIssueSummary[]> {
|
|
288
|
+
const filter = buildIssueFilter(params);
|
|
289
|
+
const data = await this.graphql<{
|
|
290
|
+
issues: { nodes: LinearIssueSummary[] };
|
|
291
|
+
}>(
|
|
292
|
+
`query LinearSearchIssues($filter: IssueFilter, $includeArchived: Boolean, $limit: Int!) {
|
|
293
|
+
issues(first: $limit, includeArchived: $includeArchived, filter: $filter, orderBy: updatedAt) {
|
|
294
|
+
nodes { ${ISSUE_SUMMARY_FIELDS} }
|
|
295
|
+
}
|
|
296
|
+
}`,
|
|
297
|
+
{
|
|
298
|
+
filter,
|
|
299
|
+
includeArchived: params.includeArchived ?? false,
|
|
300
|
+
limit: clampLimit(params.limit, 10),
|
|
301
|
+
},
|
|
302
|
+
signal,
|
|
303
|
+
);
|
|
304
|
+
return data.issues.nodes;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
public async myWork(
|
|
308
|
+
limit: number | undefined,
|
|
309
|
+
signal?: AbortSignal,
|
|
310
|
+
): Promise<{
|
|
311
|
+
assigned: LinearIssueSummary[];
|
|
312
|
+
created: LinearIssueSummary[];
|
|
313
|
+
viewer: LinearViewer;
|
|
314
|
+
}> {
|
|
315
|
+
const cappedLimit = clampLimit(limit, 10);
|
|
316
|
+
const data = await this.graphql<{
|
|
317
|
+
viewer: LinearViewer & {
|
|
318
|
+
assignedIssues: { nodes: LinearIssueSummary[] };
|
|
319
|
+
createdIssues: { nodes: LinearIssueSummary[] };
|
|
320
|
+
};
|
|
321
|
+
}>(
|
|
322
|
+
`query LinearMyWork($limit: Int!) {
|
|
323
|
+
viewer {
|
|
324
|
+
id
|
|
325
|
+
name
|
|
326
|
+
displayName
|
|
327
|
+
email
|
|
328
|
+
assignedIssues(first: $limit, orderBy: updatedAt) { nodes { ${ISSUE_SUMMARY_FIELDS} } }
|
|
329
|
+
createdIssues(first: $limit, orderBy: updatedAt) { nodes { ${ISSUE_SUMMARY_FIELDS} } }
|
|
330
|
+
}
|
|
331
|
+
}`,
|
|
332
|
+
{ limit: cappedLimit },
|
|
333
|
+
signal,
|
|
334
|
+
);
|
|
335
|
+
const { assignedIssues, createdIssues, ...viewer } = data.viewer;
|
|
336
|
+
return {
|
|
337
|
+
assigned: assignedIssues.nodes,
|
|
338
|
+
created: createdIssues.nodes,
|
|
339
|
+
viewer,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
public async states(
|
|
344
|
+
team: string,
|
|
345
|
+
signal?: AbortSignal,
|
|
346
|
+
): Promise<LinearState[]> {
|
|
347
|
+
const teamId = await this.resolveTeamId(team, signal);
|
|
348
|
+
const data = await this.graphql<{
|
|
349
|
+
team: { states: { nodes: LinearState[] } } | null;
|
|
350
|
+
}>(
|
|
351
|
+
`query LinearTeamStates($teamId: String!) {
|
|
352
|
+
team(id: $teamId) {
|
|
353
|
+
states { nodes { id name type position } }
|
|
354
|
+
}
|
|
355
|
+
}`,
|
|
356
|
+
{ teamId },
|
|
357
|
+
signal,
|
|
358
|
+
);
|
|
359
|
+
if (!data.team) throw new LinearApiError(`Linear team not found: ${team}`);
|
|
360
|
+
return [...data.team.states.nodes].sort(
|
|
361
|
+
(a, b) => (a.position ?? 0) - (b.position ?? 0),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
public async createIssue(
|
|
366
|
+
params: LinearCreateIssueParams,
|
|
367
|
+
signal?: AbortSignal,
|
|
368
|
+
): Promise<LinearIssueSummary> {
|
|
369
|
+
const teamId = await this.resolveTeamId(params.team, signal);
|
|
370
|
+
const input = compactObject({
|
|
371
|
+
assigneeId: nonEmpty(params.assigneeId),
|
|
372
|
+
description: nonEmpty(params.description),
|
|
373
|
+
dueDate: nonEmpty(params.dueDate),
|
|
374
|
+
labelIds: params.labelIds,
|
|
375
|
+
priority: params.priority,
|
|
376
|
+
projectId: nonEmpty(params.projectId),
|
|
377
|
+
stateId: nonEmpty(params.stateId),
|
|
378
|
+
teamId,
|
|
379
|
+
title: params.title,
|
|
380
|
+
});
|
|
381
|
+
const data = await this.graphql<{
|
|
382
|
+
issueCreate: { issue: LinearIssueSummary | null; success: boolean };
|
|
383
|
+
}>(
|
|
384
|
+
`mutation LinearCreateIssue($input: IssueCreateInput!) {
|
|
385
|
+
issueCreate(input: $input) { success issue { ${ISSUE_SUMMARY_FIELDS} } }
|
|
386
|
+
}`,
|
|
387
|
+
{ input },
|
|
388
|
+
signal,
|
|
389
|
+
);
|
|
390
|
+
if (!data.issueCreate.success || !data.issueCreate.issue) {
|
|
391
|
+
throw new LinearApiError(
|
|
392
|
+
"Linear issueCreate did not return a created issue.",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return data.issueCreate.issue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
public async updateIssue(
|
|
399
|
+
params: LinearUpdateIssueParams,
|
|
400
|
+
signal?: AbortSignal,
|
|
401
|
+
): Promise<LinearIssueSummary> {
|
|
402
|
+
const input = compactObject({
|
|
403
|
+
assigneeId:
|
|
404
|
+
params.assigneeId === null
|
|
405
|
+
? null
|
|
406
|
+
: nonEmpty(params.assigneeId ?? undefined),
|
|
407
|
+
description: nonEmpty(params.description),
|
|
408
|
+
dueDate:
|
|
409
|
+
params.dueDate === null ? null : nonEmpty(params.dueDate ?? undefined),
|
|
410
|
+
labelIds: params.labelIds,
|
|
411
|
+
priority: params.priority,
|
|
412
|
+
projectId:
|
|
413
|
+
params.projectId === null
|
|
414
|
+
? null
|
|
415
|
+
: nonEmpty(params.projectId ?? undefined),
|
|
416
|
+
stateId: nonEmpty(params.stateId),
|
|
417
|
+
title: nonEmpty(params.title),
|
|
418
|
+
});
|
|
419
|
+
if (Object.keys(input).length === 0) {
|
|
420
|
+
throw new LinearApiError(
|
|
421
|
+
"linear_update_issue requires at least one field to update.",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const data = await this.graphql<{
|
|
426
|
+
issueUpdate: { issue: LinearIssueSummary | null; success: boolean };
|
|
427
|
+
}>(
|
|
428
|
+
`mutation LinearUpdateIssue($id: String!, $input: IssueUpdateInput!) {
|
|
429
|
+
issueUpdate(id: $id, input: $input) { success issue { ${ISSUE_SUMMARY_FIELDS} } }
|
|
430
|
+
}`,
|
|
431
|
+
{ id: params.issueId, input },
|
|
432
|
+
signal,
|
|
433
|
+
);
|
|
434
|
+
if (!data.issueUpdate.success || !data.issueUpdate.issue) {
|
|
435
|
+
throw new LinearApiError(
|
|
436
|
+
"Linear issueUpdate did not return an updated issue.",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return data.issueUpdate.issue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
public async commentIssue(
|
|
443
|
+
issueId: string,
|
|
444
|
+
body: string,
|
|
445
|
+
signal?: AbortSignal,
|
|
446
|
+
): Promise<LinearComment> {
|
|
447
|
+
const issue = await this.issue(issueId, 0, signal);
|
|
448
|
+
const data = await this.graphql<{
|
|
449
|
+
commentCreate: { comment: LinearComment | null; success: boolean };
|
|
450
|
+
}>(
|
|
451
|
+
`mutation LinearCommentIssue($input: CommentCreateInput!) {
|
|
452
|
+
commentCreate(input: $input) {
|
|
453
|
+
success
|
|
454
|
+
comment { id body createdAt updatedAt user { id name displayName email } }
|
|
455
|
+
}
|
|
456
|
+
}`,
|
|
457
|
+
{ input: { body, issueId: issue.id } },
|
|
458
|
+
signal,
|
|
459
|
+
);
|
|
460
|
+
if (!data.commentCreate.success || !data.commentCreate.comment) {
|
|
461
|
+
throw new LinearApiError(
|
|
462
|
+
"Linear commentCreate did not return a created comment.",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return data.commentCreate.comment;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function formatOptionalMessage(message: string | undefined): string {
|
|
470
|
+
if (!message) return "";
|
|
471
|
+
return `: ${message}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function readRateLimitHeaders(headers: Headers): Record<string, string> {
|
|
475
|
+
const names = [
|
|
476
|
+
"x-complexity",
|
|
477
|
+
"x-ratelimit-complexity-limit",
|
|
478
|
+
"x-ratelimit-complexity-remaining",
|
|
479
|
+
"x-ratelimit-complexity-reset",
|
|
480
|
+
"x-ratelimit-requests-limit",
|
|
481
|
+
"x-ratelimit-requests-remaining",
|
|
482
|
+
"x-ratelimit-requests-reset",
|
|
483
|
+
];
|
|
484
|
+
const output: Record<string, string> = {};
|
|
485
|
+
for (const name of names) {
|
|
486
|
+
const value = headers.get(name);
|
|
487
|
+
if (value) output[name] = value;
|
|
488
|
+
}
|
|
489
|
+
return output;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function buildIssueFilter(params: LinearSearchParams): JsonObject {
|
|
493
|
+
const filter: JsonObject = {};
|
|
494
|
+
const query = nonEmpty(params.query);
|
|
495
|
+
if (query) {
|
|
496
|
+
const issueKey = parseIssueKey(query);
|
|
497
|
+
filter.or = [
|
|
498
|
+
...(issueKey
|
|
499
|
+
? [
|
|
500
|
+
{
|
|
501
|
+
and: [
|
|
502
|
+
{ team: { key: { eqIgnoreCase: issueKey.teamKey } } },
|
|
503
|
+
{ number: { eq: issueKey.number } },
|
|
504
|
+
],
|
|
505
|
+
},
|
|
506
|
+
]
|
|
507
|
+
: []),
|
|
508
|
+
{ searchableContent: { contains: query } },
|
|
509
|
+
{ title: { containsIgnoreCase: query } },
|
|
510
|
+
{ description: { containsIgnoreCase: query } },
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const team = nonEmpty(params.team);
|
|
515
|
+
if (team) {
|
|
516
|
+
filter.team = isUuidLike(team)
|
|
517
|
+
? { id: { eq: team } }
|
|
518
|
+
: {
|
|
519
|
+
or: [
|
|
520
|
+
{ key: { eqIgnoreCase: team } },
|
|
521
|
+
{ name: { eqIgnoreCase: team } },
|
|
522
|
+
],
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const stateFilter: JsonObject = {};
|
|
527
|
+
if (params.stateType) stateFilter.type = { eq: params.stateType };
|
|
528
|
+
const stateName = nonEmpty(params.stateName);
|
|
529
|
+
if (stateName) stateFilter.name = { eqIgnoreCase: stateName };
|
|
530
|
+
if (Object.keys(stateFilter).length > 0) filter.state = stateFilter;
|
|
531
|
+
|
|
532
|
+
const labels = params.labels
|
|
533
|
+
?.map((label) => nonEmpty(label))
|
|
534
|
+
.filter((label) => label !== undefined);
|
|
535
|
+
if (labels?.length) {
|
|
536
|
+
filter.labels = { some: { name: { in: labels } } };
|
|
537
|
+
}
|
|
538
|
+
if (params.priority !== undefined) filter.priority = { eq: params.priority };
|
|
539
|
+
|
|
540
|
+
const assigneeId = nonEmpty(params.assigneeId);
|
|
541
|
+
if (assigneeId) filter.assignee = { id: { eq: assigneeId } };
|
|
542
|
+
const assigneeEmail = nonEmpty(params.assigneeEmail);
|
|
543
|
+
if (assigneeEmail)
|
|
544
|
+
filter.assignee = { email: { eqIgnoreCase: assigneeEmail } };
|
|
545
|
+
|
|
546
|
+
const projectName = nonEmpty(params.projectName);
|
|
547
|
+
if (projectName) filter.project = { name: { eqIgnoreCase: projectName } };
|
|
548
|
+
|
|
549
|
+
return filter;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function parseIssueKey(
|
|
553
|
+
value: string,
|
|
554
|
+
): { number: number; teamKey: string } | undefined {
|
|
555
|
+
const match = /^([A-Z][A-Z0-9]+)-(\d+)$/i.exec(value.trim());
|
|
556
|
+
if (!match) return undefined;
|
|
557
|
+
|
|
558
|
+
const teamKey = match[1];
|
|
559
|
+
const issueNumber = match[2];
|
|
560
|
+
if (!teamKey || !issueNumber) return undefined;
|
|
561
|
+
|
|
562
|
+
return { number: Number(issueNumber), teamKey };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isUuidLike(value: string): boolean {
|
|
566
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
567
|
+
value,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* eslint-enable n/no-unsupported-features/node-builtins */
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatIssue,
|
|
3
|
+
formatIssueSearch,
|
|
4
|
+
formatMyWork,
|
|
5
|
+
formatStates,
|
|
6
|
+
formatTeams,
|
|
7
|
+
formatViewer,
|
|
8
|
+
} from "./format.ts";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { LinearClient } from "./client.ts";
|
|
11
|
+
import { updateLinearStatus } from "./status.ts";
|
|
12
|
+
|
|
13
|
+
export function registerLinearCommands(pi: ExtensionAPI): void {
|
|
14
|
+
const client = new LinearClient();
|
|
15
|
+
|
|
16
|
+
pi.registerCommand("linear-setup", {
|
|
17
|
+
description: "Check Linear credentials and show the authenticated user.",
|
|
18
|
+
handler: async (_args, ctx) => {
|
|
19
|
+
try {
|
|
20
|
+
const viewer = await client.viewer(ctx.signal);
|
|
21
|
+
await updateLinearStatus((value) => {
|
|
22
|
+
ctx.ui.setStatus("linear", value);
|
|
23
|
+
}, ctx.signal);
|
|
24
|
+
ctx.ui.notify(`Linear connected as ${formatViewer(viewer)}`, "info");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.registerCommand("linear-issue", {
|
|
32
|
+
description: "Fetch a Linear issue: /linear-issue ENG-123",
|
|
33
|
+
handler: async (args, ctx) => {
|
|
34
|
+
const issueId = args.trim();
|
|
35
|
+
if (!issueId) {
|
|
36
|
+
ctx.ui.notify("Usage: /linear-issue ENG-123", "error");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const issue = await client.issue(issueId, 10, ctx.signal);
|
|
42
|
+
pi.sendMessage({
|
|
43
|
+
content: formatIssue(issue),
|
|
44
|
+
customType: "linear-issue",
|
|
45
|
+
details: { issue },
|
|
46
|
+
display: true,
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.registerCommand("linear-search", {
|
|
55
|
+
description: "Search Linear issues by text: /linear-search checkout a11y",
|
|
56
|
+
handler: async (args, ctx) => {
|
|
57
|
+
const query = args.trim();
|
|
58
|
+
if (!query) {
|
|
59
|
+
ctx.ui.notify("Usage: /linear-search text", "error");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const issues = await client.searchIssues(
|
|
65
|
+
{ limit: 10, query },
|
|
66
|
+
ctx.signal,
|
|
67
|
+
);
|
|
68
|
+
pi.sendMessage({
|
|
69
|
+
content: formatIssueSearch(`Linear issue search: ${query}`, issues),
|
|
70
|
+
customType: "linear-search",
|
|
71
|
+
details: { issues, query },
|
|
72
|
+
display: true,
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.registerCommand("linear-my-work", {
|
|
81
|
+
description: "Show Linear issues assigned to and recently created by you.",
|
|
82
|
+
handler: async (_args, ctx) => {
|
|
83
|
+
try {
|
|
84
|
+
const work = await client.myWork(10, ctx.signal);
|
|
85
|
+
pi.sendMessage({
|
|
86
|
+
content: formatMyWork(work),
|
|
87
|
+
customType: "linear-my-work",
|
|
88
|
+
details: work,
|
|
89
|
+
display: true,
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.registerCommand("linear-teams", {
|
|
98
|
+
description: "List Linear teams and keys.",
|
|
99
|
+
handler: async (_args, ctx) => {
|
|
100
|
+
try {
|
|
101
|
+
const teams = await client.teams(ctx.signal);
|
|
102
|
+
pi.sendMessage({
|
|
103
|
+
content: formatTeams(teams),
|
|
104
|
+
customType: "linear-teams",
|
|
105
|
+
details: { teams },
|
|
106
|
+
display: true,
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.registerCommand("linear-states", {
|
|
115
|
+
description: "List Linear workflow states for a team: /linear-states ENG",
|
|
116
|
+
handler: async (args, ctx) => {
|
|
117
|
+
const team = args.trim();
|
|
118
|
+
if (!team) {
|
|
119
|
+
ctx.ui.notify("Usage: /linear-states TEAM_KEY", "error");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const states = await client.states(team, ctx.signal);
|
|
125
|
+
pi.sendMessage({
|
|
126
|
+
content: formatStates(team, states),
|
|
127
|
+
customType: "linear-states",
|
|
128
|
+
details: { states, team },
|
|
129
|
+
display: true,
|
|
130
|
+
});
|
|
131
|
+
} catch (error) {
|
|
132
|
+
ctx.ui.notify(errorMessage(error), "error");
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function errorMessage(error: unknown): string {
|
|
139
|
+
return error instanceof Error ? error.message : String(error);
|
|
140
|
+
}
|