@sentry/junior-linear 0.119.0 → 0.120.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
@@ -8,17 +8,18 @@ Install it alongside `@sentry/junior`:
8
8
  pnpm add @sentry/junior @sentry/junior-linear
9
9
  ```
10
10
 
11
- Then add the package name to the plugin set exported from `plugins.ts`:
11
+ Then add the plugin to the set exported from `plugins.ts`:
12
12
 
13
13
  ```ts title="plugins.ts"
14
14
  import { defineJuniorPlugins } from "@sentry/junior";
15
+ import { linearPlugin } from "@sentry/junior-linear";
15
16
 
16
- export const plugins = defineJuniorPlugins(["@sentry/junior-linear"]);
17
+ export const plugins = defineJuniorPlugins([linearPlugin()]);
17
18
  ```
18
19
 
19
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.
20
21
 
21
- The package is designed for ticket-centric work: finding issues, creating issues, updating fields, adding comments, and moving work through the normal Linear workflow without leaving Slack.
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
23
 
23
24
  Optional: set channel defaults when a Slack thread usually routes work to the same Linear destination:
24
25
 
@@ -0,0 +1 @@
1
+ export { linearPlugin } from "./plugin.js";
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ // src/plugin.ts
2
+ import {
3
+ defineJuniorPlugin
4
+ } from "@sentry/junior-plugin-api";
5
+
6
+ // src/tools/create-issue.ts
7
+ import {
8
+ definePluginTool,
9
+ PluginToolInputError,
10
+ pluginToolResultSchema
11
+ } from "@sentry/junior-plugin-api";
12
+ 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
+ var inputSchema = z.object({
16
+ team: z.string().trim().min(1).describe("Linear team name or identifier."),
17
+ title: z.string().trim().min(1).max(60).describe("Issue title."),
18
+ description: z.string().describe("Issue description.").optional(),
19
+ priority: z.enum(["low", "medium", "high", "urgent"]).describe("Linear priority.").optional(),
20
+ project: z.string().trim().min(1).describe("Linear project name or identifier.").optional()
21
+ }).strict();
22
+ var issueSchema = z.object({
23
+ id: z.string().optional(),
24
+ identifier: z.string().optional(),
25
+ title: z.string().optional(),
26
+ url: z.string().url().optional()
27
+ }).strict();
28
+ var outputSchema = pluginToolResultSchema.extend({
29
+ ok: z.literal(true),
30
+ status: z.literal("success"),
31
+ target: z.literal("createIssue"),
32
+ data: z.object({
33
+ issue: issueSchema.nullable(),
34
+ providerText: z.string()
35
+ }),
36
+ issue: issueSchema.nullable(),
37
+ providerText: z.string()
38
+ });
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
+ function collectObjects(value, objects) {
55
+ if (!value || typeof value !== "object") {
56
+ return;
57
+ }
58
+ if (Array.isArray(value)) {
59
+ for (const item of value) collectObjects(item, objects);
60
+ return;
61
+ }
62
+ const record = value;
63
+ objects.push(record);
64
+ for (const item of Object.values(record)) collectObjects(item, objects);
65
+ }
66
+ function stringField(objects, field) {
67
+ for (const object of objects) {
68
+ const value = object[field];
69
+ if (typeof value === "string" && value.trim()) return value.trim();
70
+ }
71
+ return void 0;
72
+ }
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
+ function parseJsonText(text) {
79
+ try {
80
+ return JSON.parse(text);
81
+ } catch {
82
+ return void 0;
83
+ }
84
+ }
85
+ function extractIssue(result) {
86
+ const text = providerText(result);
87
+ const objects = [];
88
+ 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
92
+ );
93
+ const identifierMatch = text.match(/\b[A-Z][A-Z0-9]*-\d+\b/);
94
+ const issue = {
95
+ id: stringField(objects, "id"),
96
+ identifier: stringField(objects, "identifier") ?? urlMatch?.[1]?.toUpperCase() ?? identifierMatch?.[0],
97
+ title: stringField(objects, "title"),
98
+ url: stringField(objects, "url") ?? urlMatch?.[0]
99
+ };
100
+ const parsed = issueSchema.safeParse(
101
+ Object.fromEntries(
102
+ Object.entries(issue).filter((entry) => entry[1] !== void 0)
103
+ )
104
+ );
105
+ if (!parsed.success || Object.keys(parsed.data).length === 0) {
106
+ return null;
107
+ }
108
+ return parsed.data;
109
+ }
110
+ function toolResult(issue, text) {
111
+ const data = { issue, providerText: text };
112
+ return {
113
+ ok: true,
114
+ status: "success",
115
+ target: "createIssue",
116
+ data,
117
+ ...data
118
+ };
119
+ }
120
+ async function annotateIssue(ctx, issue) {
121
+ if (!issue?.identifier || !issue.url) {
122
+ return;
123
+ }
124
+ await ctx.annotations?.upsert({
125
+ kind: "resource_link",
126
+ key: issue.identifier.toUpperCase(),
127
+ label: issue.identifier.toUpperCase(),
128
+ url: issue.url,
129
+ status: "open"
130
+ });
131
+ }
132
+ function required(value, name) {
133
+ if (!value?.trim()) throw new PluginToolInputError(`${name} is required`);
134
+ return value.trim();
135
+ }
136
+ function createLinearIssueTool(ctx) {
137
+ return definePluginTool({
138
+ description: "Create a Linear issue through Linear's hosted MCP provider and link the created issue to the current Junior conversation.",
139
+ inputSchema,
140
+ outputSchema,
141
+ annotations: {
142
+ destructiveHint: false,
143
+ idempotentHint: true,
144
+ openWorldHint: true,
145
+ readOnlyHint: false,
146
+ title: "Create Linear issue"
147
+ },
148
+ describeProposal(input) {
149
+ return `Create a Linear issue in ${input.team}: ${input.title}`;
150
+ },
151
+ async execute(input, options) {
152
+ const mcp = ctx.mcp;
153
+ if (!mcp) {
154
+ throw new Error("Linear MCP provider is unavailable.");
155
+ }
156
+ const conversationId = required(ctx.conversationId, "conversationId");
157
+ const toolCallId = required(options.toolCallId, "toolCallId");
158
+ if (await mcp.prepare() === "authorization_pending") {
159
+ return toolResult(null, "Authorization pending.");
160
+ }
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
+ );
214
+ }
215
+ });
216
+ }
217
+
218
+ // src/plugin.ts
219
+ function linearPlugin() {
220
+ return defineJuniorPlugin({
221
+ packageName: "@sentry/junior-linear",
222
+ manifest: {
223
+ configKeys: ["team", "project"],
224
+ description: "Linear issue tracking via hosted MCP server",
225
+ displayName: "Linear",
226
+ mcp: {
227
+ transport: "http",
228
+ url: "https://mcp.linear.app/mcp",
229
+ wrappedTools: ["create_issue"]
230
+ },
231
+ name: "linear"
232
+ },
233
+ hooks: {
234
+ tools(ctx) {
235
+ return {
236
+ createIssue: createLinearIssueTool(ctx)
237
+ };
238
+ }
239
+ }
240
+ });
241
+ }
242
+ export {
243
+ linearPlugin
244
+ };
@@ -0,0 +1,3 @@
1
+ import { type PluginRegistration } from "@sentry/junior-plugin-api";
2
+ /** Register Linear's hosted MCP provider and Junior-owned wrapper tools. */
3
+ export declare function linearPlugin(): PluginRegistration;
@@ -0,0 +1,40 @@
1
+ import { type ToolRegistrationHookContext } from "@sentry/junior-plugin-api";
2
+ /** Create a Linear issue through the hosted MCP provider and link it to the conversation. */
3
+ export declare function createLinearIssueTool(ctx: ToolRegistrationHookContext): import("@sentry/junior-plugin-api").PluginToolDefinition<{
4
+ team: string;
5
+ title: string;
6
+ description?: string | undefined;
7
+ priority?: "low" | "medium" | "high" | "urgent" | undefined;
8
+ project?: string | undefined;
9
+ }, {
10
+ [x: string]: unknown;
11
+ ok: true;
12
+ status: "success";
13
+ target: "createIssue";
14
+ data: {
15
+ issue: {
16
+ id?: string | undefined;
17
+ identifier?: string | undefined;
18
+ title?: string | undefined;
19
+ url?: string | undefined;
20
+ } | null;
21
+ providerText: string;
22
+ };
23
+ issue: {
24
+ id?: string | undefined;
25
+ identifier?: string | undefined;
26
+ title?: string | undefined;
27
+ url?: string | undefined;
28
+ } | null;
29
+ providerText: string;
30
+ truncated?: boolean | undefined;
31
+ continuation?: {
32
+ arguments: Record<string, unknown>;
33
+ reason?: string | undefined;
34
+ } | undefined;
35
+ error?: string | {
36
+ kind: string;
37
+ message: string;
38
+ retryable?: boolean | undefined;
39
+ } | undefined;
40
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-linear",
3
- "version": "0.119.0",
3
+ "version": "0.120.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -11,8 +11,29 @@
11
11
  "url": "git+https://github.com/getsentry/junior.git",
12
12
  "directory": "packages/junior-linear"
13
13
  },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
14
20
  "files": [
15
- "plugin.yaml",
21
+ "dist",
16
22
  "skills"
17
- ]
23
+ ],
24
+ "dependencies": {
25
+ "zod": "^4.4.3",
26
+ "@sentry/junior-plugin-api": "0.120.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.9.1",
30
+ "oxlint": "^1.66.0",
31
+ "tsup": "^8.5.1",
32
+ "typescript": "^6.0.3"
33
+ },
34
+ "scripts": {
35
+ "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
36
+ "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tsup.config.ts",
37
+ "typecheck": "tsc --noEmit"
38
+ }
18
39
  }
@@ -63,10 +63,11 @@ Attribute the reporter by name when clear from the thread (e.g. "Raised by Alice
63
63
  - No session framing remains (channel refs, slash commands, @mentions, Slack thread IDs).
64
64
  - Body structure matches complexity — no empty sections, no restated title, no raw research dump.
65
65
 
66
- If any gate fails, revise and re-check before calling the MCP create/update tool.
66
+ If any gate fails, revise and re-check before calling the Linear create/update tool.
67
67
 
68
68
  6. Execute:
69
69
 
70
+ - Create new issues with `linear_createIssue`.
70
71
  - 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.
71
72
  - Check for duplicates silently before creating a new issue when the request appears related to existing work.
72
73
 
package/plugin.yaml DELETED
@@ -1,10 +0,0 @@
1
- name: linear
2
- display-name: Linear
3
- description: Linear issue tracking via hosted MCP server
4
-
5
- config-keys:
6
- - team
7
- - project
8
-
9
- mcp:
10
- url: https://mcp.linear.app/mcp