@sentry/junior-linear 0.120.0 → 0.122.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/README.md CHANGED
@@ -19,7 +19,7 @@ export const plugins = defineJuniorPlugins([linearPlugin()]);
19
19
 
20
20
  This package does not require a shared `LINEAR_API_KEY` or a custom OAuth app for the default setup. Each user connects their own Linear account the first time Junior calls a Linear MCP tool. Junior sends the authorization link privately and resumes the same Slack thread automatically after the user authorizes.
21
21
 
22
- Issue creation uses a Junior-owned tool backed by Linear's hosted MCP connection. This lets Junior prevent duplicate retries and link created issues to the conversation while keeping Linear's existing OAuth flow. Other Linear operations continue to use the hosted MCP tools.
22
+ Issue creation uses a Junior-owned tool backed by Linear's hosted MCP connection. This lets Junior link created issues to the conversation while keeping Linear's existing OAuth flow. Other Linear operations continue to use the hosted MCP tools.
23
23
 
24
24
  Optional: set channel defaults when a Slack thread usually routes work to the same Linear destination:
25
25
 
package/dist/index.js CHANGED
@@ -6,12 +6,9 @@ import {
6
6
  // src/tools/create-issue.ts
7
7
  import {
8
8
  definePluginTool,
9
- PluginToolInputError,
10
9
  pluginToolResultSchema
11
10
  } from "@sentry/junior-plugin-api";
12
11
  import { z } from "zod";
13
- var CREATE_ISSUE_STATE_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
14
- var CREATE_ISSUE_LOCK_TTL_MS = 6e4;
15
12
  var inputSchema = z.object({
16
13
  team: z.string().trim().min(1).describe("Linear team name or identifier."),
17
14
  title: z.string().trim().min(1).max(60).describe("Issue title."),
@@ -30,27 +27,10 @@ var outputSchema = pluginToolResultSchema.extend({
30
27
  status: z.literal("success"),
31
28
  target: z.literal("createIssue"),
32
29
  data: z.object({
33
- issue: issueSchema.nullable(),
34
- providerText: z.string()
30
+ issue: issueSchema.nullable()
35
31
  }),
36
- issue: issueSchema.nullable(),
37
- providerText: z.string()
32
+ issue: issueSchema.nullable()
38
33
  });
39
- var completedStateSchema = z.object({
40
- createdAtMs: z.number(),
41
- issue: issueSchema.nullable(),
42
- providerText: z.string(),
43
- status: z.literal("completed")
44
- }).strict();
45
- var pendingStateSchema = z.object({
46
- createdAtMs: z.number(),
47
- status: z.literal("pending")
48
- }).strict();
49
- var stateSchema = z.union([completedStateSchema, pendingStateSchema]);
50
- function parseState(value) {
51
- const parsed = stateSchema.safeParse(value);
52
- return parsed.success ? parsed.data : void 0;
53
- }
54
34
  function collectObjects(value, objects) {
55
35
  if (!value || typeof value !== "object") {
56
36
  return;
@@ -70,11 +50,6 @@ function stringField(objects, field) {
70
50
  }
71
51
  return void 0;
72
52
  }
73
- function providerText(result) {
74
- return result.content.filter(
75
- (part) => part.type === "text"
76
- ).map((part) => part.text).join("\n\n").trim();
77
- }
78
53
  function parseJsonText(text) {
79
54
  try {
80
55
  return JSON.parse(text);
@@ -83,14 +58,20 @@ function parseJsonText(text) {
83
58
  }
84
59
  }
85
60
  function extractIssue(result) {
86
- const text = providerText(result);
87
61
  const objects = [];
88
62
  collectObjects(result.structuredContent, objects);
89
- collectObjects(parseJsonText(text), objects);
90
- const urlMatch = text.match(
91
- /https:\/\/linear\.app\/[^\s<>)"']+\/issue\/([A-Z][A-Z0-9]*-\d+)[^\s<>)"']*/i
63
+ const textParts = result.content.flatMap(
64
+ (part) => part.type === "text" ? [part.text] : []
92
65
  );
93
- const identifierMatch = text.match(/\b[A-Z][A-Z0-9]*-\d+\b/);
66
+ for (const text of textParts) {
67
+ collectObjects(parseJsonText(text), objects);
68
+ }
69
+ const urlMatch = textParts.map(
70
+ (text) => text.match(
71
+ /https:\/\/linear\.app\/[^\s<>)"']+\/issue\/([A-Z][A-Z0-9]*-\d+)[^\s<>)"']*/i
72
+ )
73
+ ).find((match) => match !== null);
74
+ const identifierMatch = textParts.map((text) => text.match(/\b[A-Z][A-Z0-9]*-\d+\b/)).find((match) => match !== null);
94
75
  const issue = {
95
76
  id: stringField(objects, "id"),
96
77
  identifier: stringField(objects, "identifier") ?? urlMatch?.[1]?.toUpperCase() ?? identifierMatch?.[0],
@@ -107,16 +88,22 @@ function extractIssue(result) {
107
88
  }
108
89
  return parsed.data;
109
90
  }
110
- function toolResult(issue, text) {
111
- const data = { issue, providerText: text };
91
+ function toolResult(issue, content) {
92
+ const data = { issue };
112
93
  return {
113
- ok: true,
114
- status: "success",
115
- target: "createIssue",
116
- data,
117
- ...data
94
+ content,
95
+ details: {
96
+ ok: true,
97
+ status: "success",
98
+ target: "createIssue",
99
+ data,
100
+ ...data
101
+ }
118
102
  };
119
103
  }
104
+ function authorizationPendingResult() {
105
+ return toolResult(null, [{ type: "text", text: "Authorization pending." }]);
106
+ }
120
107
  async function annotateIssue(ctx, issue) {
121
108
  if (!issue?.identifier || !issue.url) {
122
109
  return;
@@ -129,10 +116,6 @@ async function annotateIssue(ctx, issue) {
129
116
  status: "open"
130
117
  });
131
118
  }
132
- function required(value, name) {
133
- if (!value?.trim()) throw new PluginToolInputError(`${name} is required`);
134
- return value.trim();
135
- }
136
119
  function createLinearIssueTool(ctx) {
137
120
  return definePluginTool({
138
121
  description: "Create a Linear issue through Linear's hosted MCP provider and link the created issue to the current Junior conversation.",
@@ -140,7 +123,7 @@ function createLinearIssueTool(ctx) {
140
123
  outputSchema,
141
124
  annotations: {
142
125
  destructiveHint: false,
143
- idempotentHint: true,
126
+ idempotentHint: false,
144
127
  openWorldHint: true,
145
128
  readOnlyHint: false,
146
129
  title: "Create Linear issue"
@@ -153,64 +136,106 @@ function createLinearIssueTool(ctx) {
153
136
  if (!mcp) {
154
137
  throw new Error("Linear MCP provider is unavailable.");
155
138
  }
156
- const conversationId = required(ctx.conversationId, "conversationId");
157
- const toolCallId = required(options.toolCallId, "toolCallId");
139
+ const result = await mcp.callTool({
140
+ name: "save_issue",
141
+ arguments: input,
142
+ toolCallId: options.toolCallId
143
+ });
144
+ if (result.status === "authorization_pending") {
145
+ return authorizationPendingResult();
146
+ }
147
+ if (result.status === "error") {
148
+ throw new Error(result.message);
149
+ }
150
+ const issue = extractIssue(result);
151
+ await annotateIssue(ctx, issue);
152
+ return toolResult(issue, result.content);
153
+ }
154
+ });
155
+ }
156
+
157
+ // src/tools/update-issue.ts
158
+ import {
159
+ definePluginTool as definePluginTool2,
160
+ pluginToolResultSchema as pluginToolResultSchema2
161
+ } from "@sentry/junior-plugin-api";
162
+ import { z as z2 } from "zod";
163
+ var nullableName = (description) => z2.string().trim().min(1).nullable().describe(description).optional();
164
+ var inputSchema2 = z2.object({
165
+ id: z2.string().trim().min(1).describe("Linear issue identifier or ID to update."),
166
+ assignee: nullableName(
167
+ 'Assignee name, email, identifier, or "me". Use null to clear.'
168
+ ),
169
+ cycle: nullableName("Cycle name or identifier. Use null to clear."),
170
+ description: z2.string().nullable().describe("Issue description. Use null to clear.").optional(),
171
+ estimate: z2.number().nonnegative().nullable().describe("Issue estimate. Use null to clear.").optional(),
172
+ labels: z2.array(z2.string().trim().min(1)).describe("Complete set of issue label names or identifiers.").optional(),
173
+ priority: z2.enum(["low", "medium", "high", "urgent"]).nullable().describe("Linear priority. Use null to clear.").optional(),
174
+ project: nullableName("Project name or identifier. Use null to clear."),
175
+ state: z2.string().trim().min(1).describe("Workflow state name or identifier.").optional(),
176
+ team: z2.string().trim().min(1).describe("Team name or identifier.").optional(),
177
+ title: z2.string().trim().min(1).max(60).describe("Issue title.").optional()
178
+ }).strict().superRefine((input, ctx) => {
179
+ if (Object.keys(input).every((key) => key === "id")) {
180
+ ctx.addIssue({
181
+ code: "custom",
182
+ message: "At least one issue field must be provided."
183
+ });
184
+ }
185
+ });
186
+ var outputSchema2 = pluginToolResultSchema2.extend({
187
+ ok: z2.literal(true),
188
+ status: z2.literal("success"),
189
+ target: z2.literal("updateIssue")
190
+ });
191
+ function toolResult2(content) {
192
+ return {
193
+ content,
194
+ details: {
195
+ ok: true,
196
+ status: "success",
197
+ target: "updateIssue"
198
+ }
199
+ };
200
+ }
201
+ function authorizationPendingResult2() {
202
+ return toolResult2([{ type: "text", text: "Authorization pending." }]);
203
+ }
204
+ function createLinearUpdateIssueTool(ctx) {
205
+ return definePluginTool2({
206
+ description: "Update an existing Linear issue through Linear's hosted MCP provider. Fetch the issue first when a change could overwrite existing fields.",
207
+ inputSchema: inputSchema2,
208
+ outputSchema: outputSchema2,
209
+ annotations: {
210
+ destructiveHint: false,
211
+ idempotentHint: true,
212
+ openWorldHint: true,
213
+ readOnlyHint: false,
214
+ title: "Update Linear issue"
215
+ },
216
+ describeProposal(input) {
217
+ return `Update Linear issue ${input.id}`;
218
+ },
219
+ async execute(input, options) {
220
+ const mcp = ctx.mcp;
221
+ if (!mcp) {
222
+ throw new Error("Linear MCP provider is unavailable.");
223
+ }
158
224
  if (await mcp.prepare() === "authorization_pending") {
159
- return toolResult(null, "Authorization pending.");
225
+ return authorizationPendingResult2();
226
+ }
227
+ const result = await mcp.callTool({
228
+ name: "save_issue",
229
+ arguments: input,
230
+ toolCallId: options.toolCallId
231
+ });
232
+ if (result.status === "authorization_pending") {
233
+ return authorizationPendingResult2();
234
+ }
235
+ if (result.status === "error") {
236
+ throw new Error(result.message);
160
237
  }
161
- const key = `createIssue:${conversationId}:${toolCallId}`;
162
- return await ctx.state.withLock(
163
- `${key}:lock`,
164
- CREATE_ISSUE_LOCK_TTL_MS,
165
- async () => {
166
- const state = parseState(await ctx.state.get(key));
167
- if (state?.status === "completed") {
168
- await annotateIssue(ctx, state.issue);
169
- return toolResult(state.issue, state.providerText);
170
- }
171
- if (state?.status === "pending") {
172
- throw new Error(
173
- "Linear issue creation for this tool call has an uncertain pending result; refusing to create a duplicate issue."
174
- );
175
- }
176
- await ctx.state.set(
177
- key,
178
- { status: "pending", createdAtMs: Date.now() },
179
- CREATE_ISSUE_STATE_TTL_MS
180
- );
181
- const result = await mcp.callTool({
182
- name: "create_issue",
183
- arguments: input,
184
- toolCallId
185
- });
186
- if (result.status === "authorization_pending") {
187
- await ctx.state.delete(key);
188
- return toolResult(null, "Authorization pending.");
189
- }
190
- if (result.status === "error") {
191
- await ctx.state.delete(key);
192
- throw new Error(result.message);
193
- }
194
- const text = providerText(result);
195
- const issue = extractIssue(result);
196
- const completedState = {
197
- status: "completed",
198
- createdAtMs: Date.now(),
199
- issue,
200
- providerText: text
201
- };
202
- try {
203
- await ctx.state.set(key, completedState, CREATE_ISSUE_STATE_TTL_MS);
204
- } catch (error) {
205
- throw new Error(
206
- "Linear issue was created, but Junior could not persist the completed issue state.",
207
- { cause: error }
208
- );
209
- }
210
- await annotateIssue(ctx, issue);
211
- return toolResult(issue, text);
212
- }
213
- );
238
+ return toolResult2(result.content);
214
239
  }
215
240
  });
216
241
  }
@@ -226,14 +251,15 @@ function linearPlugin() {
226
251
  mcp: {
227
252
  transport: "http",
228
253
  url: "https://mcp.linear.app/mcp",
229
- wrappedTools: ["create_issue"]
254
+ wrappedTools: ["save_issue"]
230
255
  },
231
256
  name: "linear"
232
257
  },
233
258
  hooks: {
234
259
  tools(ctx) {
235
260
  return {
236
- createIssue: createLinearIssueTool(ctx)
261
+ createIssue: createLinearIssueTool(ctx),
262
+ updateIssue: createLinearUpdateIssueTool(ctx)
237
263
  };
238
264
  }
239
265
  }
@@ -1,4 +1,4 @@
1
- import { type ToolRegistrationHookContext } from "@sentry/junior-plugin-api";
1
+ import { type PluginToolResultEnvelope, type ToolRegistrationHookContext } from "@sentry/junior-plugin-api";
2
2
  /** Create a Linear issue through the hosted MCP provider and link it to the conversation. */
3
3
  export declare function createLinearIssueTool(ctx: ToolRegistrationHookContext): import("@sentry/junior-plugin-api").PluginToolDefinition<{
4
4
  team: string;
@@ -18,7 +18,6 @@ export declare function createLinearIssueTool(ctx: ToolRegistrationHookContext):
18
18
  title?: string | undefined;
19
19
  url?: string | undefined;
20
20
  } | null;
21
- providerText: string;
22
21
  };
23
22
  issue: {
24
23
  id?: string | undefined;
@@ -26,7 +25,6 @@ export declare function createLinearIssueTool(ctx: ToolRegistrationHookContext):
26
25
  title?: string | undefined;
27
26
  url?: string | undefined;
28
27
  } | null;
29
- providerText: string;
30
28
  truncated?: boolean | undefined;
31
29
  continuation?: {
32
30
  arguments: Record<string, unknown>;
@@ -37,4 +35,33 @@ export declare function createLinearIssueTool(ctx: ToolRegistrationHookContext):
37
35
  message: string;
38
36
  retryable?: boolean | undefined;
39
37
  } | undefined;
40
- }>;
38
+ }, PluginToolResultEnvelope<{
39
+ [x: string]: unknown;
40
+ ok: true;
41
+ status: "success";
42
+ target: "createIssue";
43
+ data: {
44
+ issue: {
45
+ id?: string | undefined;
46
+ identifier?: string | undefined;
47
+ title?: string | undefined;
48
+ url?: string | undefined;
49
+ } | null;
50
+ };
51
+ issue: {
52
+ id?: string | undefined;
53
+ identifier?: string | undefined;
54
+ title?: string | undefined;
55
+ url?: string | undefined;
56
+ } | null;
57
+ truncated?: boolean | undefined;
58
+ continuation?: {
59
+ arguments: Record<string, unknown>;
60
+ reason?: string | undefined;
61
+ } | undefined;
62
+ error?: string | {
63
+ kind: string;
64
+ message: string;
65
+ retryable?: boolean | undefined;
66
+ } | undefined;
67
+ }>>;
@@ -0,0 +1,47 @@
1
+ import { type PluginToolResultEnvelope, type ToolRegistrationHookContext } from "@sentry/junior-plugin-api";
2
+ /** Update a Linear issue through the hosted MCP provider. */
3
+ export declare function createLinearUpdateIssueTool(ctx: ToolRegistrationHookContext): import("@sentry/junior-plugin-api").PluginToolDefinition<{
4
+ id: string;
5
+ assignee?: string | null | undefined;
6
+ cycle?: string | null | undefined;
7
+ description?: string | null | undefined;
8
+ estimate?: number | null | undefined;
9
+ labels?: string[] | undefined;
10
+ priority?: "low" | "medium" | "high" | "urgent" | null | undefined;
11
+ project?: string | null | undefined;
12
+ state?: string | undefined;
13
+ team?: string | undefined;
14
+ title?: string | undefined;
15
+ }, {
16
+ [x: string]: unknown;
17
+ ok: true;
18
+ status: "success";
19
+ target: "updateIssue";
20
+ data?: unknown;
21
+ truncated?: boolean | undefined;
22
+ continuation?: {
23
+ arguments: Record<string, unknown>;
24
+ reason?: string | undefined;
25
+ } | undefined;
26
+ error?: string | {
27
+ kind: string;
28
+ message: string;
29
+ retryable?: boolean | undefined;
30
+ } | undefined;
31
+ }, PluginToolResultEnvelope<{
32
+ [x: string]: unknown;
33
+ ok: true;
34
+ status: "success";
35
+ target: "updateIssue";
36
+ data?: unknown;
37
+ truncated?: boolean | undefined;
38
+ continuation?: {
39
+ arguments: Record<string, unknown>;
40
+ reason?: string | undefined;
41
+ } | undefined;
42
+ error?: string | {
43
+ kind: string;
44
+ message: string;
45
+ retryable?: boolean | undefined;
46
+ } | undefined;
47
+ }>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-linear",
3
- "version": "0.120.0",
3
+ "version": "0.122.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "zod": "^4.4.3",
26
- "@sentry/junior-plugin-api": "0.120.0"
26
+ "@sentry/junior-plugin-api": "0.122.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^25.9.1",
@@ -68,6 +68,7 @@ If any gate fails, revise and re-check before calling the Linear create/update t
68
68
  6. Execute:
69
69
 
70
70
  - Create new issues with `linear_createIssue`.
71
+ - Update existing issues with `linear_updateIssue`.
71
72
  - For updates, prefer partial changes over full rewrites. Fetch current issue state first if the mutation could overwrite structured fields or duplicate an existing comment.
72
73
  - Check for duplicates silently before creating a new issue when the request appears related to existing work.
73
74