@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 +4 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +244 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/tools/create-issue.d.ts +40 -0
- package/package.json +24 -3
- package/skills/linear/SKILL.md +2 -1
- package/plugin.yaml +0 -10
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
|
|
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([
|
|
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
|
-
|
|
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
|
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
}
|
package/skills/linear/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|