Skip to content

Commit 0fe96ec

Browse files
author
StackMemory Bot (CLI)
committed
feat(mcp): add linear comment CRUD tools (STA-493)
Add 3 MCP tools for Linear comment management: - linear_create_comment: create comment on issue (workpad init) - linear_update_comment: edit existing comment by ID (workpad update) - linear_list_comments: list comments on issue (find workpad) Also adds createComment/updateComment/getComments to LinearClient.
1 parent 6e87945 commit 0fe96ec

4 files changed

Lines changed: 410 additions & 0 deletions

File tree

src/integrations/linear/client.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,4 +895,123 @@ export class LinearClient {
895895
};
896896
}
897897
}
898+
899+
// --- Comment CRUD ---
900+
901+
async createComment(
902+
issueId: string,
903+
body: string
904+
): Promise<{ id: string; body: string; createdAt: string }> {
905+
const mutation = `
906+
mutation CreateComment($input: CommentCreateInput!) {
907+
commentCreate(input: $input) {
908+
success
909+
comment {
910+
id
911+
body
912+
createdAt
913+
user { id name }
914+
}
915+
}
916+
}
917+
`;
918+
919+
const result = await this.graphql<{
920+
commentCreate: {
921+
success: boolean;
922+
comment: {
923+
id: string;
924+
body: string;
925+
createdAt: string;
926+
user: { id: string; name: string };
927+
};
928+
};
929+
}>(mutation, { input: { issueId, body } });
930+
931+
if (!result.commentCreate.success) {
932+
throw new IntegrationError(
933+
'Failed to create comment',
934+
ErrorCode.LINEAR_API_ERROR,
935+
{ issueId }
936+
);
937+
}
938+
939+
return result.commentCreate.comment;
940+
}
941+
942+
async updateComment(
943+
commentId: string,
944+
body: string
945+
): Promise<{ id: string; body: string; updatedAt: string }> {
946+
const mutation = `
947+
mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {
948+
commentUpdate(id: $id, input: $input) {
949+
success
950+
comment {
951+
id
952+
body
953+
updatedAt
954+
}
955+
}
956+
}
957+
`;
958+
959+
const result = await this.graphql<{
960+
commentUpdate: {
961+
success: boolean;
962+
comment: { id: string; body: string; updatedAt: string };
963+
};
964+
}>(mutation, { id: commentId, input: { body } });
965+
966+
if (!result.commentUpdate.success) {
967+
throw new IntegrationError(
968+
'Failed to update comment',
969+
ErrorCode.LINEAR_API_ERROR,
970+
{ commentId }
971+
);
972+
}
973+
974+
return result.commentUpdate.comment;
975+
}
976+
977+
async getComments(
978+
issueId: string
979+
): Promise<
980+
Array<{
981+
id: string;
982+
body: string;
983+
createdAt: string;
984+
user: { name: string } | null;
985+
}>
986+
> {
987+
const query = `
988+
query GetComments($issueId: String!) {
989+
issue(id: $issueId) {
990+
comments(first: 100) {
991+
nodes {
992+
id
993+
body
994+
createdAt
995+
user { name }
996+
}
997+
}
998+
}
999+
}
1000+
`;
1001+
1002+
const result = await this.graphql<{
1003+
issue: {
1004+
comments: {
1005+
nodes: Array<{
1006+
id: string;
1007+
body: string;
1008+
createdAt: string;
1009+
user: { name: string } | null;
1010+
}>;
1011+
};
1012+
};
1013+
}>(query, { issueId });
1014+
1015+
return result.issue.comments.nodes;
1016+
}
8981017
}

src/integrations/mcp/handlers/linear-handlers.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,4 +265,117 @@ export class LinearHandlers {
265265
};
266266
}
267267
}
268+
269+
/**
270+
* Create a comment on a Linear issue
271+
*/
272+
async handleLinearCreateComment(args: any): Promise<any> {
273+
try {
274+
const { issue_id, body } = args;
275+
if (!issue_id || !body) {
276+
throw new Error('issue_id and body are required');
277+
}
278+
279+
const client = await this.getClient();
280+
const comment = await client.createComment(issue_id, body);
281+
282+
return {
283+
content: [
284+
{
285+
type: 'text',
286+
text: `Comment created on ${issue_id}\nID: ${comment.id}\nPreview: ${body.slice(0, 100)}${body.length > 100 ? '...' : ''}`,
287+
},
288+
],
289+
metadata: {
290+
id: comment.id,
291+
issueId: issue_id,
292+
createdAt: comment.createdAt,
293+
},
294+
};
295+
} catch (error: unknown) {
296+
logger.error(
297+
'Error creating Linear comment',
298+
error instanceof Error ? error : new Error(String(error))
299+
);
300+
throw error;
301+
}
302+
}
303+
304+
/**
305+
* Update an existing comment on a Linear issue
306+
*/
307+
async handleLinearUpdateComment(args: any): Promise<any> {
308+
try {
309+
const { comment_id, body } = args;
310+
if (!comment_id || !body) {
311+
throw new Error('comment_id and body are required');
312+
}
313+
314+
const client = await this.getClient();
315+
const comment = await client.updateComment(comment_id, body);
316+
317+
return {
318+
content: [
319+
{
320+
type: 'text',
321+
text: `Comment ${comment_id} updated\nPreview: ${body.slice(0, 100)}${body.length > 100 ? '...' : ''}`,
322+
},
323+
],
324+
metadata: {
325+
id: comment.id,
326+
updatedAt: comment.updatedAt,
327+
},
328+
};
329+
} catch (error: unknown) {
330+
logger.error(
331+
'Error updating Linear comment',
332+
error instanceof Error ? error : new Error(String(error))
333+
);
334+
throw error;
335+
}
336+
}
337+
338+
/**
339+
* List comments on a Linear issue
340+
*/
341+
async handleLinearListComments(args: any): Promise<any> {
342+
try {
343+
const { issue_id } = args;
344+
if (!issue_id) {
345+
throw new Error('issue_id is required');
346+
}
347+
348+
const client = await this.getClient();
349+
const comments = await client.getComments(issue_id);
350+
351+
const lines = comments.map(
352+
(c) =>
353+
`${c.id.slice(0, 8)} | ${c.user?.name ?? 'unknown'} | ${new Date(c.createdAt).toISOString().slice(0, 10)} | ${c.body.slice(0, 60).replace(/\n/g, ' ')}${c.body.length > 60 ? '...' : ''}`
354+
);
355+
356+
const text =
357+
comments.length > 0
358+
? `${comments.length} comments on ${issue_id}:\n${lines.join('\n')}`
359+
: `No comments on ${issue_id}`;
360+
361+
return {
362+
content: [{ type: 'text', text }],
363+
metadata: {
364+
count: comments.length,
365+
comments: comments.map((c) => ({
366+
id: c.id,
367+
author: c.user?.name,
368+
createdAt: c.createdAt,
369+
bodyPreview: c.body.slice(0, 200),
370+
})),
371+
},
372+
};
373+
} catch (error: unknown) {
374+
logger.error(
375+
'Error listing Linear comments',
376+
error instanceof Error ? error : new Error(String(error))
377+
);
378+
throw error;
379+
}
380+
}
268381
}

src/integrations/mcp/server.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,18 @@ class LocalStackMemoryMCP {
15211521
result = await this.handleLinearStatus(args);
15221522
break;
15231523

1524+
case 'linear_create_comment':
1525+
result = await this.handleLinearCreateComment(args);
1526+
break;
1527+
1528+
case 'linear_update_comment':
1529+
result = await this.handleLinearUpdateComment(args);
1530+
break;
1531+
1532+
case 'linear_list_comments':
1533+
result = await this.handleLinearListComments(args);
1534+
break;
1535+
15241536
case 'get_traces':
15251537
result = await this.handleGetTraces(args);
15261538
break;
@@ -3071,6 +3083,118 @@ class LocalStackMemoryMCP {
30713083
}
30723084
}
30733085

3086+
private async getLinearClient() {
3087+
const { LinearClient } = await import('../linear/client.js');
3088+
const tokens = this.linearAuthManager.loadTokens();
3089+
if (!tokens) {
3090+
throw new Error('Linear not configured. Run: stackmemory linear setup');
3091+
}
3092+
return new LinearClient({
3093+
apiKey: tokens.accessToken,
3094+
useBearer: true,
3095+
onUnauthorized: async () => {
3096+
const refreshed = await this.linearAuthManager.refreshAccessToken();
3097+
return refreshed.accessToken;
3098+
},
3099+
});
3100+
}
3101+
3102+
private async handleLinearCreateComment(args: any) {
3103+
const { issue_id, body } = args;
3104+
if (!issue_id || !body) {
3105+
return {
3106+
content: [
3107+
{ type: 'text', text: 'Error: issue_id and body are required' },
3108+
],
3109+
};
3110+
}
3111+
try {
3112+
const client = await this.getLinearClient();
3113+
const comment = await client.createComment(issue_id, body);
3114+
return {
3115+
content: [
3116+
{
3117+
type: 'text',
3118+
text: `Comment created on ${issue_id}\nID: ${comment.id}\nPreview: ${body.slice(0, 100)}${body.length > 100 ? '...' : ''}`,
3119+
},
3120+
],
3121+
metadata: { id: comment.id, issueId: issue_id },
3122+
};
3123+
} catch (error: unknown) {
3124+
const msg = error instanceof Error ? error.message : String(error);
3125+
return {
3126+
content: [{ type: 'text', text: `Error creating comment: ${msg}` }],
3127+
};
3128+
}
3129+
}
3130+
3131+
private async handleLinearUpdateComment(args: any) {
3132+
const { comment_id, body } = args;
3133+
if (!comment_id || !body) {
3134+
return {
3135+
content: [
3136+
{ type: 'text', text: 'Error: comment_id and body are required' },
3137+
],
3138+
};
3139+
}
3140+
try {
3141+
const client = await this.getLinearClient();
3142+
const comment = await client.updateComment(comment_id, body);
3143+
return {
3144+
content: [
3145+
{
3146+
type: 'text',
3147+
text: `Comment ${comment_id} updated\nPreview: ${body.slice(0, 100)}${body.length > 100 ? '...' : ''}`,
3148+
},
3149+
],
3150+
metadata: { id: comment.id, updatedAt: comment.updatedAt },
3151+
};
3152+
} catch (error: unknown) {
3153+
const msg = error instanceof Error ? error.message : String(error);
3154+
return {
3155+
content: [{ type: 'text', text: `Error updating comment: ${msg}` }],
3156+
};
3157+
}
3158+
}
3159+
3160+
private async handleLinearListComments(args: any) {
3161+
const { issue_id } = args;
3162+
if (!issue_id) {
3163+
return {
3164+
content: [{ type: 'text', text: 'Error: issue_id is required' }],
3165+
};
3166+
}
3167+
try {
3168+
const client = await this.getLinearClient();
3169+
const comments = await client.getComments(issue_id);
3170+
const lines = comments.map(
3171+
(c) =>
3172+
`${c.id.slice(0, 8)} | ${c.user?.name ?? 'unknown'} | ${new Date(c.createdAt).toISOString().slice(0, 10)} | ${c.body.slice(0, 60).replace(/\n/g, ' ')}${c.body.length > 60 ? '...' : ''}`
3173+
);
3174+
const text =
3175+
comments.length > 0
3176+
? `${comments.length} comments on ${issue_id}:\n${lines.join('\n')}`
3177+
: `No comments on ${issue_id}`;
3178+
return {
3179+
content: [{ type: 'text', text }],
3180+
metadata: {
3181+
count: comments.length,
3182+
comments: comments.map((c) => ({
3183+
id: c.id,
3184+
author: c.user?.name,
3185+
createdAt: c.createdAt,
3186+
bodyPreview: c.body.slice(0, 200),
3187+
})),
3188+
},
3189+
};
3190+
} catch (error: unknown) {
3191+
const msg = error instanceof Error ? error.message : String(error);
3192+
return {
3193+
content: [{ type: 'text', text: `Error listing comments: ${msg}` }],
3194+
};
3195+
}
3196+
}
3197+
30743198
private async handleGetTraces(args: any) {
30753199
const { type, minScore, limit = 20 } = args;
30763200

0 commit comments

Comments
 (0)