@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.
- package/LICENSE +21 -0
- package/dist/src/agent-client.d.ts +195 -0
- package/dist/src/agent-client.d.ts.map +1 -0
- package/dist/src/agent-client.js +548 -0
- package/dist/src/agent-session.d.ts +284 -0
- package/dist/src/agent-session.d.ts.map +1 -0
- package/dist/src/agent-session.js +875 -0
- package/dist/src/checkbox-utils.d.ts +88 -0
- package/dist/src/checkbox-utils.d.ts.map +1 -0
- package/dist/src/checkbox-utils.js +120 -0
- package/dist/src/constants.d.ts +77 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +87 -0
- package/dist/src/errors.d.ts +79 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +155 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +16 -0
- package/dist/src/retry.d.ts +43 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +73 -0
- package/dist/src/types.d.ts +412 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +121 -0
- package/dist/src/utils.d.ts +52 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +277 -0
- package/package.json +59 -0
|
@@ -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
|
+
}
|