@supaku/agentfactory-linear 0.1.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.
@@ -0,0 +1,548 @@
1
+ import { LinearClient, AgentActivitySignal as LinearAgentActivitySignal, IssueRelationType as LinearIssueRelationType, } from '@linear/sdk';
2
+ import { LinearApiError, LinearStatusTransitionError } from './errors';
3
+ import { withRetry, DEFAULT_RETRY_CONFIG } from './retry';
4
+ /**
5
+ * Core Linear Agent Client
6
+ * Wraps @linear/sdk with retry logic and helper methods
7
+ */
8
+ export class LinearAgentClient {
9
+ client;
10
+ retryConfig;
11
+ statusCache = new Map();
12
+ constructor(config) {
13
+ this.client = new LinearClient({
14
+ apiKey: config.apiKey,
15
+ ...(config.baseUrl && { apiUrl: config.baseUrl }),
16
+ });
17
+ this.retryConfig = {
18
+ ...DEFAULT_RETRY_CONFIG,
19
+ ...config.retry,
20
+ };
21
+ }
22
+ /**
23
+ * Get the underlying LinearClient instance
24
+ */
25
+ get linearClient() {
26
+ return this.client;
27
+ }
28
+ /**
29
+ * Execute an operation with retry logic
30
+ */
31
+ async withRetry(fn) {
32
+ return withRetry(fn, {
33
+ config: this.retryConfig,
34
+ onRetry: ({ attempt, delay }) => {
35
+ console.log(`[LinearAgentClient] Retry attempt ${attempt + 1}/${this.retryConfig.maxRetries}, ` +
36
+ `waiting ${delay}ms`);
37
+ },
38
+ });
39
+ }
40
+ /**
41
+ * Fetch an issue by ID or identifier (e.g., "SUP-50")
42
+ */
43
+ async getIssue(issueIdOrIdentifier) {
44
+ return this.withRetry(async () => {
45
+ const issue = await this.client.issue(issueIdOrIdentifier);
46
+ if (!issue) {
47
+ throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
48
+ }
49
+ return issue;
50
+ });
51
+ }
52
+ /**
53
+ * Update an issue's properties
54
+ */
55
+ async updateIssue(issueId, data) {
56
+ return this.withRetry(async () => {
57
+ const payload = await this.client.updateIssue(issueId, data);
58
+ if (!payload.success) {
59
+ throw new LinearApiError(`Failed to update issue: ${issueId}`, 400, payload);
60
+ }
61
+ return this.client.issue(issueId);
62
+ });
63
+ }
64
+ /**
65
+ * Remove the assignee from an issue (unassign)
66
+ * Used when agent completes work to enable clean handoff visibility
67
+ */
68
+ async unassignIssue(issueId) {
69
+ return this.withRetry(async () => {
70
+ // Linear SDK expects null to clear assignee
71
+ const payload = await this.client.updateIssue(issueId, {
72
+ assigneeId: null,
73
+ });
74
+ if (!payload.success) {
75
+ throw new LinearApiError(`Failed to unassign issue: ${issueId}`, 400, payload);
76
+ }
77
+ return this.client.issue(issueId);
78
+ });
79
+ }
80
+ /**
81
+ * Get workflow states for a team (cached)
82
+ */
83
+ async getTeamStatuses(teamId) {
84
+ if (this.statusCache.has(teamId)) {
85
+ return this.statusCache.get(teamId);
86
+ }
87
+ return this.withRetry(async () => {
88
+ const team = await this.client.team(teamId);
89
+ const states = await team.states();
90
+ const mapping = {};
91
+ for (const state of states.nodes) {
92
+ mapping[state.name] = state.id;
93
+ }
94
+ this.statusCache.set(teamId, mapping);
95
+ return mapping;
96
+ });
97
+ }
98
+ /**
99
+ * Update issue status by name (e.g., "Started", "Finished")
100
+ */
101
+ async updateIssueStatus(issueId, statusName) {
102
+ return this.withRetry(async () => {
103
+ const issue = await this.client.issue(issueId);
104
+ const team = await issue.team;
105
+ if (!team) {
106
+ throw new LinearApiError(`Cannot find team for issue: ${issueId}`, 400);
107
+ }
108
+ const statuses = await this.getTeamStatuses(team.id);
109
+ const stateId = statuses[statusName];
110
+ if (!stateId) {
111
+ const currentState = await issue.state;
112
+ throw new LinearStatusTransitionError(`Status "${statusName}" not found in team "${team.name}"`, issueId, currentState?.name ?? 'unknown', statusName);
113
+ }
114
+ return this.updateIssue(issueId, { stateId });
115
+ });
116
+ }
117
+ /**
118
+ * Create a comment on an issue
119
+ */
120
+ async createComment(issueId, body) {
121
+ return this.withRetry(async () => {
122
+ const payload = await this.client.createComment({
123
+ issueId,
124
+ body,
125
+ });
126
+ if (!payload.success) {
127
+ throw new LinearApiError(`Failed to create comment on issue: ${issueId}`, 400, payload);
128
+ }
129
+ const comment = await payload.comment;
130
+ if (!comment) {
131
+ throw new LinearApiError(`Comment created but not returned for issue: ${issueId}`, 500);
132
+ }
133
+ return comment;
134
+ });
135
+ }
136
+ /**
137
+ * Get comments for an issue
138
+ */
139
+ async getIssueComments(issueId) {
140
+ return this.withRetry(async () => {
141
+ const issue = await this.client.issue(issueId);
142
+ const comments = await issue.comments();
143
+ return comments.nodes;
144
+ });
145
+ }
146
+ /**
147
+ * Create a new issue
148
+ */
149
+ async createIssue(input) {
150
+ return this.withRetry(async () => {
151
+ const payload = await this.client.createIssue(input);
152
+ if (!payload.success) {
153
+ throw new LinearApiError(`Failed to create issue: ${input.title}`, 400, payload);
154
+ }
155
+ const issue = await payload.issue;
156
+ if (!issue) {
157
+ throw new LinearApiError(`Issue created but not returned: ${input.title}`, 500);
158
+ }
159
+ return issue;
160
+ });
161
+ }
162
+ /**
163
+ * Get the authenticated user (the agent)
164
+ */
165
+ async getViewer() {
166
+ return this.withRetry(() => this.client.viewer);
167
+ }
168
+ /**
169
+ * Get a team by ID or key
170
+ */
171
+ async getTeam(teamIdOrKey) {
172
+ return this.withRetry(() => this.client.team(teamIdOrKey));
173
+ }
174
+ /**
175
+ * Create an agent activity using the native Linear Agent API
176
+ *
177
+ * @param input - The activity input containing session ID, content, and options
178
+ * @returns Result indicating success and the created activity ID
179
+ */
180
+ async createAgentActivity(input) {
181
+ return this.withRetry(async () => {
182
+ const signalMap = {
183
+ auth: LinearAgentActivitySignal.Auth,
184
+ continue: LinearAgentActivitySignal.Continue,
185
+ select: LinearAgentActivitySignal.Select,
186
+ stop: LinearAgentActivitySignal.Stop,
187
+ };
188
+ const payload = await this.client.createAgentActivity({
189
+ agentSessionId: input.agentSessionId,
190
+ content: input.content,
191
+ ephemeral: input.ephemeral,
192
+ id: input.id,
193
+ signal: input.signal ? signalMap[input.signal] : undefined,
194
+ });
195
+ if (!payload.success) {
196
+ throw new LinearApiError(`Failed to create agent activity for session: ${input.agentSessionId}`, 400, payload);
197
+ }
198
+ const activity = await payload.agentActivity;
199
+ return {
200
+ success: true,
201
+ activityId: activity?.id,
202
+ };
203
+ });
204
+ }
205
+ /**
206
+ * Update an agent session
207
+ *
208
+ * Use this to set the externalUrl (linking to agent dashboard/logs)
209
+ * within 10 seconds of receiving a webhook to avoid appearing unresponsive.
210
+ *
211
+ * @param input - The session update input containing sessionId and updates
212
+ * @returns Result indicating success and the session ID
213
+ */
214
+ async updateAgentSession(input) {
215
+ return this.withRetry(async () => {
216
+ const payload = await this.client.updateAgentSession(input.sessionId, {
217
+ externalUrls: input.externalUrls,
218
+ externalLink: input.externalLink,
219
+ plan: input.plan,
220
+ });
221
+ if (!payload.success) {
222
+ throw new LinearApiError(`Failed to update agent session: ${input.sessionId}`, 400, payload);
223
+ }
224
+ const session = await payload.agentSession;
225
+ return {
226
+ success: true,
227
+ sessionId: session?.id,
228
+ };
229
+ });
230
+ }
231
+ /**
232
+ * Create an agent session on an issue
233
+ *
234
+ * Use this to programmatically create a Linear AgentSession when status transitions
235
+ * occur without explicit agent mention/delegation (e.g., Icebox -> Backlog).
236
+ *
237
+ * This enables the Linear Agent Session UI to show real-time activities even when
238
+ * the agent work is triggered by status changes rather than user mentions.
239
+ *
240
+ * @param input - The session creation input containing issueId and optional external URLs
241
+ * @returns Result indicating success and the created session ID
242
+ */
243
+ async createAgentSessionOnIssue(input) {
244
+ return this.withRetry(async () => {
245
+ const payload = await this.client.agentSessionCreateOnIssue({
246
+ issueId: input.issueId,
247
+ externalUrls: input.externalUrls,
248
+ externalLink: input.externalLink,
249
+ });
250
+ if (!payload.success) {
251
+ throw new LinearApiError(`Failed to create agent session on issue: ${input.issueId}`, 400, payload);
252
+ }
253
+ const session = await payload.agentSession;
254
+ return {
255
+ success: true,
256
+ sessionId: session?.id,
257
+ };
258
+ });
259
+ }
260
+ // ============================================================================
261
+ // ISSUE RELATION METHODS
262
+ // ============================================================================
263
+ /**
264
+ * Create a relation between two issues
265
+ *
266
+ * @param input - The relation input containing issue IDs and relation type
267
+ * @returns Result indicating success and the created relation ID
268
+ *
269
+ * Relation types:
270
+ * - 'related': General association between issues
271
+ * - 'blocks': Source issue blocks the related issue from progressing
272
+ * - 'duplicate': Source issue is a duplicate of the related issue
273
+ */
274
+ async createIssueRelation(input) {
275
+ return this.withRetry(async () => {
276
+ // Map our string type to the SDK's enum
277
+ const typeMap = {
278
+ related: LinearIssueRelationType.Related,
279
+ blocks: LinearIssueRelationType.Blocks,
280
+ duplicate: LinearIssueRelationType.Duplicate,
281
+ };
282
+ const payload = await this.client.createIssueRelation({
283
+ issueId: input.issueId,
284
+ relatedIssueId: input.relatedIssueId,
285
+ type: typeMap[input.type],
286
+ });
287
+ if (!payload.success) {
288
+ throw new LinearApiError(`Failed to create issue relation: ${input.issueId} -> ${input.relatedIssueId}`, 400, payload);
289
+ }
290
+ const relation = await payload.issueRelation;
291
+ return {
292
+ success: true,
293
+ relationId: relation?.id,
294
+ };
295
+ });
296
+ }
297
+ /**
298
+ * Create multiple relations from a source issue to multiple target issues
299
+ *
300
+ * @param input - Batch input containing source issue, target issues, and relation type
301
+ * @returns Batch result with successful relation IDs and any errors
302
+ */
303
+ async createIssueRelationsBatch(input) {
304
+ const relationIds = [];
305
+ const errors = [];
306
+ for (const targetIssueId of input.targetIssueIds) {
307
+ try {
308
+ const result = await this.createIssueRelation({
309
+ issueId: input.sourceIssueId,
310
+ relatedIssueId: targetIssueId,
311
+ type: input.type,
312
+ });
313
+ if (result.relationId) {
314
+ relationIds.push(result.relationId);
315
+ }
316
+ }
317
+ catch (error) {
318
+ errors.push({
319
+ targetIssueId,
320
+ error: error instanceof Error ? error.message : String(error),
321
+ });
322
+ }
323
+ }
324
+ return {
325
+ success: errors.length === 0,
326
+ relationIds,
327
+ errors,
328
+ };
329
+ }
330
+ /**
331
+ * Get all relations for an issue (both outgoing and incoming)
332
+ *
333
+ * @param issueId - The issue ID or identifier (e.g., "SUP-123")
334
+ * @returns Relations result with both directions of relationships
335
+ */
336
+ async getIssueRelations(issueId) {
337
+ return this.withRetry(async () => {
338
+ const issue = await this.client.issue(issueId);
339
+ if (!issue) {
340
+ throw new LinearApiError(`Issue not found: ${issueId}`, 404);
341
+ }
342
+ // Get outgoing relations (this issue -> other issues)
343
+ const relationsConnection = await issue.relations();
344
+ const relations = [];
345
+ for (const relation of relationsConnection.nodes) {
346
+ const relatedIssue = await relation.relatedIssue;
347
+ relations.push({
348
+ id: relation.id,
349
+ type: relation.type,
350
+ issueId: issue.id,
351
+ issueIdentifier: issue.identifier,
352
+ relatedIssueId: relatedIssue?.id ?? '',
353
+ relatedIssueIdentifier: relatedIssue?.identifier,
354
+ createdAt: relation.createdAt,
355
+ });
356
+ }
357
+ // Get incoming relations (other issues -> this issue)
358
+ const inverseRelationsConnection = await issue.inverseRelations();
359
+ const inverseRelations = [];
360
+ for (const relation of inverseRelationsConnection.nodes) {
361
+ const sourceIssue = await relation.issue;
362
+ inverseRelations.push({
363
+ id: relation.id,
364
+ type: relation.type,
365
+ issueId: sourceIssue?.id ?? '',
366
+ issueIdentifier: sourceIssue?.identifier,
367
+ relatedIssueId: issue.id,
368
+ relatedIssueIdentifier: issue.identifier,
369
+ createdAt: relation.createdAt,
370
+ });
371
+ }
372
+ return { relations, inverseRelations };
373
+ });
374
+ }
375
+ /**
376
+ * Delete an issue relation
377
+ *
378
+ * @param relationId - The relation ID to delete
379
+ * @returns Result indicating success
380
+ */
381
+ async deleteIssueRelation(relationId) {
382
+ return this.withRetry(async () => {
383
+ const payload = await this.client.deleteIssueRelation(relationId);
384
+ if (!payload.success) {
385
+ throw new LinearApiError(`Failed to delete issue relation: ${relationId}`, 400, payload);
386
+ }
387
+ return { success: true };
388
+ });
389
+ }
390
+ // ============================================================================
391
+ // SUB-ISSUE METHODS (for coordination work type)
392
+ // ============================================================================
393
+ /**
394
+ * Fetch all child issues (sub-issues) of a parent issue
395
+ *
396
+ * @param issueIdOrIdentifier - The parent issue ID or identifier (e.g., "SUP-100")
397
+ * @returns Array of child issues
398
+ */
399
+ async getSubIssues(issueIdOrIdentifier) {
400
+ return this.withRetry(async () => {
401
+ const parentIssue = await this.client.issue(issueIdOrIdentifier);
402
+ if (!parentIssue) {
403
+ throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
404
+ }
405
+ const children = await parentIssue.children();
406
+ return children.nodes;
407
+ });
408
+ }
409
+ /**
410
+ * Check if an issue has a parent (is a child/sub-issue)
411
+ *
412
+ * @param issueIdOrIdentifier - The issue ID or identifier
413
+ * @returns True if the issue has a parent issue
414
+ */
415
+ async isChildIssue(issueIdOrIdentifier) {
416
+ return this.withRetry(async () => {
417
+ const issue = await this.client.issue(issueIdOrIdentifier);
418
+ if (!issue) {
419
+ throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
420
+ }
421
+ const parent = await issue.parent;
422
+ return parent != null;
423
+ });
424
+ }
425
+ /**
426
+ * Check if an issue has child issues (is a parent issue)
427
+ *
428
+ * @param issueIdOrIdentifier - The issue ID or identifier
429
+ * @returns True if the issue has at least one child issue
430
+ */
431
+ async isParentIssue(issueIdOrIdentifier) {
432
+ return this.withRetry(async () => {
433
+ const issue = await this.client.issue(issueIdOrIdentifier);
434
+ if (!issue) {
435
+ throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
436
+ }
437
+ const children = await issue.children();
438
+ return children.nodes.length > 0;
439
+ });
440
+ }
441
+ /**
442
+ * Get lightweight sub-issue statuses (no blocking relations)
443
+ *
444
+ * Returns identifier, title, and status for each sub-issue.
445
+ * Used by QA and acceptance agents to validate sub-issue completion
446
+ * without the overhead of fetching the full dependency graph.
447
+ *
448
+ * @param issueIdOrIdentifier - The parent issue ID or identifier
449
+ * @returns Array of sub-issue statuses
450
+ */
451
+ async getSubIssueStatuses(issueIdOrIdentifier) {
452
+ return this.withRetry(async () => {
453
+ const parentIssue = await this.client.issue(issueIdOrIdentifier);
454
+ if (!parentIssue) {
455
+ throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
456
+ }
457
+ const children = await parentIssue.children();
458
+ const results = [];
459
+ for (const child of children.nodes) {
460
+ const state = await child.state;
461
+ results.push({
462
+ identifier: child.identifier,
463
+ title: child.title,
464
+ status: state?.name ?? 'Unknown',
465
+ });
466
+ }
467
+ return results;
468
+ });
469
+ }
470
+ /**
471
+ * Get sub-issues with their blocking relations for dependency graph building
472
+ *
473
+ * Builds a complete dependency graph of a parent issue's children, including
474
+ * which sub-issues block which other sub-issues. This is used by the coordinator
475
+ * agent to determine execution order.
476
+ *
477
+ * @param issueIdOrIdentifier - The parent issue ID or identifier
478
+ * @returns The sub-issue dependency graph
479
+ */
480
+ async getSubIssueGraph(issueIdOrIdentifier) {
481
+ return this.withRetry(async () => {
482
+ const parentIssue = await this.client.issue(issueIdOrIdentifier);
483
+ if (!parentIssue) {
484
+ throw new LinearApiError(`Issue not found: ${issueIdOrIdentifier}`, 404);
485
+ }
486
+ const children = await parentIssue.children();
487
+ const subIssueIds = new Set(children.nodes.map((c) => c.id));
488
+ const subIssueIdentifiers = new Map();
489
+ // Build identifier map for all sub-issues
490
+ for (const child of children.nodes) {
491
+ subIssueIdentifiers.set(child.id, child.identifier);
492
+ }
493
+ const graphNodes = [];
494
+ for (const child of children.nodes) {
495
+ const state = await child.state;
496
+ const labels = await child.labels();
497
+ // Get relations to find blocking dependencies
498
+ const relations = await child.relations();
499
+ const inverseRelations = await child.inverseRelations();
500
+ const blockedBy = [];
501
+ const blocks = [];
502
+ // Check inverse relations - other issues blocking this one
503
+ for (const rel of inverseRelations.nodes) {
504
+ if (rel.type === 'blocks') {
505
+ const sourceIssue = await rel.issue;
506
+ if (sourceIssue && subIssueIds.has(sourceIssue.id)) {
507
+ blockedBy.push(sourceIssue.identifier);
508
+ }
509
+ }
510
+ }
511
+ // Check outgoing relations - this issue blocking others
512
+ for (const rel of relations.nodes) {
513
+ if (rel.type === 'blocks') {
514
+ const relatedIssue = await rel.relatedIssue;
515
+ if (relatedIssue && subIssueIds.has(relatedIssue.id)) {
516
+ blocks.push(relatedIssue.identifier);
517
+ }
518
+ }
519
+ }
520
+ graphNodes.push({
521
+ issue: {
522
+ id: child.id,
523
+ identifier: child.identifier,
524
+ title: child.title,
525
+ description: child.description ?? undefined,
526
+ status: state?.name,
527
+ priority: child.priority,
528
+ labels: labels.nodes.map((l) => l.name),
529
+ url: child.url,
530
+ },
531
+ blockedBy,
532
+ blocks,
533
+ });
534
+ }
535
+ return {
536
+ parentId: parentIssue.id,
537
+ parentIdentifier: parentIssue.identifier,
538
+ subIssues: graphNodes,
539
+ };
540
+ });
541
+ }
542
+ }
543
+ /**
544
+ * Create a configured LinearAgentClient instance
545
+ */
546
+ export function createLinearAgentClient(config) {
547
+ return new LinearAgentClient(config);
548
+ }