@sentry/junior-linear 0.162.0 → 0.164.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
|
@@ -21,6 +21,14 @@ This package does not require a shared `LINEAR_API_KEY` or a custom OAuth app fo
|
|
|
21
21
|
|
|
22
22
|
Linear operations use Linear's hosted MCP tools directly. When an issue is created through that path, Junior links it to the current conversation.
|
|
23
23
|
|
|
24
|
+
To run watches or event tasks when Linear issues are created:
|
|
25
|
+
|
|
26
|
+
1. Set `LINEAR_WEBHOOK_SECRET` to the Linear webhook signing secret.
|
|
27
|
+
2. Create a Linear webhook for the `Issue` resource at `https://<junior-host>/api/webhooks/linear`.
|
|
28
|
+
3. Redeploy Junior.
|
|
29
|
+
|
|
30
|
+
The plugin verifies the `Linear-Signature` header and publishes `issue.created` for the issue identifier and the team key. Team event tasks use the Linear team key, such as `SRE`.
|
|
31
|
+
|
|
24
32
|
Optional: set channel defaults when a Slack thread usually routes work to the same Linear destination:
|
|
25
33
|
|
|
26
34
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -2,11 +2,162 @@
|
|
|
2
2
|
import {
|
|
3
3
|
defineJuniorPlugin
|
|
4
4
|
} from "@sentry/junior-plugin-api";
|
|
5
|
+
import { z as z2 } from "zod";
|
|
6
|
+
|
|
7
|
+
// src/resource-events/issue.ts
|
|
8
|
+
var LINEAR_ISSUE_EVENTS = ["issue.created"];
|
|
9
|
+
function linearIssueResource(input) {
|
|
10
|
+
const identifier = input.identifier.toUpperCase();
|
|
11
|
+
return {
|
|
12
|
+
identifier,
|
|
13
|
+
label: `Linear issue ${identifier}`,
|
|
14
|
+
namespace: "linear"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function linearTeamResource(input) {
|
|
18
|
+
const teamKey = input.teamKey.toUpperCase();
|
|
19
|
+
return {
|
|
20
|
+
identifier: teamKey,
|
|
21
|
+
label: `Linear team ${teamKey}`,
|
|
22
|
+
namespace: "linear"
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/webhooks/handler.ts
|
|
27
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
28
|
+
|
|
29
|
+
// src/webhooks/resource-events.ts
|
|
5
30
|
import { z } from "zod";
|
|
6
|
-
var
|
|
7
|
-
|
|
31
|
+
var issueWebhookSchema = z.object({
|
|
32
|
+
action: z.string(),
|
|
33
|
+
createdAt: z.string().optional(),
|
|
34
|
+
data: z.object({
|
|
35
|
+
assignee: z.object({ name: z.string().optional() }).passthrough().optional().nullable(),
|
|
36
|
+
description: z.string().optional().nullable(),
|
|
37
|
+
id: z.string().min(1),
|
|
8
38
|
identifier: z.string().trim().min(1),
|
|
39
|
+
labels: z.array(z.object({ name: z.string().min(1) }).passthrough()).optional(),
|
|
40
|
+
priorityLabel: z.string().optional(),
|
|
41
|
+
project: z.object({ name: z.string().optional() }).passthrough().optional().nullable(),
|
|
42
|
+
state: z.object({ name: z.string().optional() }).passthrough().optional().nullable(),
|
|
43
|
+
team: z.object({ key: z.string().trim().min(1) }).passthrough().optional(),
|
|
44
|
+
teamKey: z.string().trim().min(1).optional(),
|
|
45
|
+
title: z.string().optional(),
|
|
9
46
|
url: z.url()
|
|
47
|
+
}).passthrough(),
|
|
48
|
+
type: z.string(),
|
|
49
|
+
url: z.url().optional(),
|
|
50
|
+
webhookTimestamp: z.number().finite().optional()
|
|
51
|
+
}).passthrough();
|
|
52
|
+
function issueText(issue) {
|
|
53
|
+
const labels = issue.labels?.map((label) => label.name).join(", ");
|
|
54
|
+
const parts = [
|
|
55
|
+
issue.title ? `Title: ${issue.title}` : void 0,
|
|
56
|
+
issue.description ? `Description: ${issue.description}` : void 0,
|
|
57
|
+
issue.state?.name ? `State: ${issue.state.name}` : void 0,
|
|
58
|
+
issue.priorityLabel ? `Priority: ${issue.priorityLabel}` : void 0,
|
|
59
|
+
issue.project?.name ? `Project: ${issue.project.name}` : void 0,
|
|
60
|
+
labels ? `Labels: ${labels}` : void 0,
|
|
61
|
+
issue.assignee?.name ? `Assignee: ${issue.assignee.name}` : void 0,
|
|
62
|
+
`URL: ${issue.url}`
|
|
63
|
+
].filter((part) => part !== void 0);
|
|
64
|
+
return parts.length > 0 ? parts.join("\n") : void 0;
|
|
65
|
+
}
|
|
66
|
+
function occurredAtMs(input) {
|
|
67
|
+
const createdAt = input.createdAt ? Date.parse(input.createdAt) : Number.NaN;
|
|
68
|
+
if (Number.isFinite(createdAt)) return createdAt;
|
|
69
|
+
if (Number.isFinite(input.webhookTimestamp)) return input.webhookTimestamp;
|
|
70
|
+
return Date.now();
|
|
71
|
+
}
|
|
72
|
+
function normalizeLinearResourceEvents(input) {
|
|
73
|
+
if (input.linearEvent.toLowerCase() !== "issue") return [];
|
|
74
|
+
const parsed = issueWebhookSchema.safeParse(input.body);
|
|
75
|
+
if (!parsed.success || parsed.data.action !== "create" || parsed.data.type.toLowerCase() !== "issue") {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const issue = parsed.data.data;
|
|
79
|
+
const teamKey = issue.team?.key ?? issue.teamKey;
|
|
80
|
+
if (!teamKey) return [];
|
|
81
|
+
const issueResource = linearIssueResource({ identifier: issue.identifier });
|
|
82
|
+
const teamResource = linearTeamResource({ teamKey });
|
|
83
|
+
const eventType = "issue.created";
|
|
84
|
+
const event = {
|
|
85
|
+
eventKey: `linear:${issue.id}:${eventType}`,
|
|
86
|
+
eventType,
|
|
87
|
+
occurredAtMs: occurredAtMs(parsed.data),
|
|
88
|
+
trustedSummary: `${issueResource.label} was created.`,
|
|
89
|
+
data: {
|
|
90
|
+
issueId: issue.id,
|
|
91
|
+
issueIdentifier: issueResource.identifier,
|
|
92
|
+
teamKey: teamResource.identifier,
|
|
93
|
+
url: issue.url
|
|
94
|
+
},
|
|
95
|
+
untrustedText: issueText(issue)
|
|
96
|
+
};
|
|
97
|
+
return [
|
|
98
|
+
{ ...event, identifier: issueResource.identifier },
|
|
99
|
+
{ ...event, identifier: teamResource.identifier }
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/webhooks/handler.ts
|
|
104
|
+
function verifyLinearSignature(body, signature, secret) {
|
|
105
|
+
if (!secret || !/^[0-9a-f]{64}$/i.test(signature)) return false;
|
|
106
|
+
const actual = Buffer.from(signature.toLowerCase());
|
|
107
|
+
const expected = Buffer.from(
|
|
108
|
+
createHmac("sha256", secret).update(body).digest("hex")
|
|
109
|
+
);
|
|
110
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
111
|
+
}
|
|
112
|
+
function parseJson(body) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(body);
|
|
115
|
+
} catch {
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function createLinearWebhookRoute(args) {
|
|
120
|
+
return {
|
|
121
|
+
method: "POST",
|
|
122
|
+
path: "/api/webhooks/linear",
|
|
123
|
+
async handler(request) {
|
|
124
|
+
const rawBody = await request.text();
|
|
125
|
+
const signature = request.headers.get("linear-signature") ?? "";
|
|
126
|
+
if (!verifyLinearSignature(rawBody, signature, args.webhookSecret())) {
|
|
127
|
+
return new Response("Unauthorized", { status: 401 });
|
|
128
|
+
}
|
|
129
|
+
const body = parseJson(rawBody);
|
|
130
|
+
if (body === void 0) {
|
|
131
|
+
return new Response("Malformed Linear webhook", { status: 400 });
|
|
132
|
+
}
|
|
133
|
+
const delivery = request.headers.get("linear-delivery")?.trim();
|
|
134
|
+
const linearEvent = request.headers.get("linear-event")?.trim();
|
|
135
|
+
if (!delivery || !linearEvent) {
|
|
136
|
+
return new Response("Malformed Linear webhook headers", {
|
|
137
|
+
status: 400
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const events = normalizeLinearResourceEvents({ body, linearEvent });
|
|
141
|
+
for (const event of events) {
|
|
142
|
+
await args.resourceEvents.publish(event);
|
|
143
|
+
}
|
|
144
|
+
return new Response(events.length ? "Accepted" : "Ignored", {
|
|
145
|
+
status: 200
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/webhooks/secret.ts
|
|
152
|
+
function linearWebhookSecret() {
|
|
153
|
+
return process.env.LINEAR_WEBHOOK_SECRET?.trim() || void 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/plugin.ts
|
|
157
|
+
var saveIssueResultSchema = z2.object({
|
|
158
|
+
issue: z2.object({
|
|
159
|
+
identifier: z2.string().trim().min(1),
|
|
160
|
+
url: z2.url()
|
|
10
161
|
}).passthrough()
|
|
11
162
|
}).passthrough();
|
|
12
163
|
async function annotateCreatedIssue(ctx) {
|
|
@@ -35,10 +186,29 @@ async function annotateCreatedIssue(ctx) {
|
|
|
35
186
|
function linearPlugin() {
|
|
36
187
|
return defineJuniorPlugin({
|
|
37
188
|
packageName: "@sentry/junior-linear",
|
|
189
|
+
resourceEvents: {
|
|
190
|
+
resourceTypes: [
|
|
191
|
+
{
|
|
192
|
+
type: "issue",
|
|
193
|
+
supportedEvents: [...LINEAR_ISSUE_EVENTS],
|
|
194
|
+
suggestedEvents: [...LINEAR_ISSUE_EVENTS]
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "team",
|
|
198
|
+
supportedEvents: [...LINEAR_ISSUE_EVENTS],
|
|
199
|
+
suggestedEvents: [...LINEAR_ISSUE_EVENTS]
|
|
200
|
+
}
|
|
201
|
+
],
|
|
202
|
+
isEnabled: () => Boolean(linearWebhookSecret()),
|
|
203
|
+
normalizeIdentifier: (identifier) => identifier.toUpperCase()
|
|
204
|
+
},
|
|
38
205
|
manifest: {
|
|
39
206
|
configKeys: ["team", "project"],
|
|
40
|
-
description: "Linear issue tracking via hosted MCP server",
|
|
207
|
+
description: "Linear issue tracking via hosted MCP server and issue webhooks",
|
|
41
208
|
displayName: "Linear",
|
|
209
|
+
envVars: {
|
|
210
|
+
LINEAR_WEBHOOK_SECRET: {}
|
|
211
|
+
},
|
|
42
212
|
mcp: {
|
|
43
213
|
transport: "http",
|
|
44
214
|
url: "https://mcp.linear.app/mcp"
|
|
@@ -46,7 +216,15 @@ function linearPlugin() {
|
|
|
46
216
|
name: "linear"
|
|
47
217
|
},
|
|
48
218
|
hooks: {
|
|
49
|
-
afterMcpTool: annotateCreatedIssue
|
|
219
|
+
afterMcpTool: annotateCreatedIssue,
|
|
220
|
+
routes(ctx) {
|
|
221
|
+
return [
|
|
222
|
+
createLinearWebhookRoute({
|
|
223
|
+
resourceEvents: ctx.resourceEvents,
|
|
224
|
+
webhookSecret: linearWebhookSecret
|
|
225
|
+
})
|
|
226
|
+
];
|
|
227
|
+
}
|
|
50
228
|
}
|
|
51
229
|
});
|
|
52
230
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SubscribableResource } from "@sentry/junior-plugin-api";
|
|
2
|
+
export declare const LINEAR_ISSUE_EVENTS: readonly ["issue.created"];
|
|
3
|
+
/** Build the stable Linear issue identity shared by tools and webhooks. */
|
|
4
|
+
export declare function linearIssueResource(input: {
|
|
5
|
+
identifier: string;
|
|
6
|
+
}): Pick<SubscribableResource, "identifier" | "label" | "namespace">;
|
|
7
|
+
/** Build the stable Linear team identity used for team-scoped events. */
|
|
8
|
+
export declare function linearTeamResource(input: {
|
|
9
|
+
teamKey: string;
|
|
10
|
+
}): Pick<SubscribableResource, "identifier" | "label" | "namespace">;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginRoute, ResourceEventPublisher } from "@sentry/junior-plugin-api";
|
|
2
|
+
/** Create the public, signed Linear resource-event webhook route. */
|
|
3
|
+
export declare function createLinearWebhookRoute(args: {
|
|
4
|
+
resourceEvents: ResourceEventPublisher;
|
|
5
|
+
webhookSecret(): string | undefined;
|
|
6
|
+
}): PluginRoute;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ResourceEventInput } from "@sentry/junior-plugin-api";
|
|
2
|
+
/** Normalize one verified Linear webhook into issue- and team-scoped events. */
|
|
3
|
+
export declare function normalizeLinearResourceEvents(input: {
|
|
4
|
+
body: unknown;
|
|
5
|
+
linearEvent: string;
|
|
6
|
+
}): ResourceEventInput[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-linear",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.164.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -23,17 +23,19 @@
|
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"zod": "^4.4.3",
|
|
26
|
-
"@sentry/junior-plugin-api": "0.
|
|
26
|
+
"@sentry/junior-plugin-api": "0.164.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^25.9.1",
|
|
30
30
|
"oxlint": "^1.66.0",
|
|
31
31
|
"tsup": "^8.5.1",
|
|
32
|
-
"typescript": "^6.0.3"
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"vitest": "^4.1.7"
|
|
33
34
|
},
|
|
34
35
|
"scripts": {
|
|
35
36
|
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
36
|
-
"lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tsup.config.ts",
|
|
37
|
+
"lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts vitest.config.ts",
|
|
38
|
+
"test": "vitest run",
|
|
37
39
|
"typecheck": "tsc --noEmit"
|
|
38
40
|
}
|
|
39
41
|
}
|