@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 saveIssueResultSchema = z.object({
7
- issue: z.object({
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[];
@@ -0,0 +1,2 @@
1
+ /** Read the secret used to verify Linear webhook deliveries. */
2
+ export declare function linearWebhookSecret(): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-linear",
3
- "version": "0.162.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.162.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
  }