diff --git a/packages/docs/src/content/docs/concepts/tasks.md b/packages/docs/src/content/docs/concepts/tasks.md index 20a92371f7..f1076c9581 100644 --- a/packages/docs/src/content/docs/concepts/tasks.md +++ b/packages/docs/src/content/docs/concepts/tasks.md @@ -15,10 +15,10 @@ Tasks are saved instructions that Junior runs later. They are built into `@sentr ## Task Types -| Type | Trigger | Example | -| ---- | ------- | ------- | -| Scheduled task | A date or recurring schedule | “Every Monday at 9am, post a project recap” | -| Event task | A resource event from a plugin | “When this issue closes, summarize it here” | +| Type | Trigger | Example | +| -------------- | ------------------------------ | ------------------------------------------- | +| Scheduled task | A date or recurring schedule | “Every Monday at 9am, post a project recap” | +| Event task | A resource event from a plugin | “When this issue closes, summarize it here” | A resource subscription is different: it follows one resource temporarily without storing an instruction. See [Resource Subscriptions](/concepts/resource-subscriptions/). @@ -48,9 +48,10 @@ Event tasks require a plugin that publishes the selected resource and event: ```text when ACME-42 closes, summarize the resolution in this channel +whenever a new issue is created in Linear team SRE, investigate it and comment findings on the issue ``` -If the plugin is disabled, the task remains visible but cannot receive events until a compatible plugin is enabled again. +If the plugin is disabled, the task remains visible but cannot receive events until a compatible plugin is enabled again. Each plugin page lists the resources and events it can publish. ## Access and Delivery diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index 59eedae50b..16ae5e5d2c 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -27,7 +27,7 @@ fixed behavior that code must control. | [Datadog](/extend/datadog-plugin/) | Logs, metrics, and incidents | | [GitHub](/extend/github-plugin/) | Repository, issue, and pull-request workflows | | [Hex](/extend/hex-plugin/) | Hex projects and runs | -| [Linear](/extend/linear-plugin/) | Issues and projects | +| [Linear](/extend/linear-plugin/) | Issues, projects, and issue webhooks | | [Maintenance](/extend/maintenance-plugin/) | Repository maintenance workflows | | [Memory](/extend/memory-plugin/) | Long-term scoped memory | | [Notion](/extend/notion-plugin/) | Notion content | diff --git a/packages/docs/src/content/docs/extend/linear-plugin.md b/packages/docs/src/content/docs/extend/linear-plugin.md index 5acf0107bf..a54650c242 100644 --- a/packages/docs/src/content/docs/extend/linear-plugin.md +++ b/packages/docs/src/content/docs/extend/linear-plugin.md @@ -1,31 +1,26 @@ --- title: Linear Plugin -description: Configure the hosted Linear MCP server for issue search and ticket workflow operations. +description: Configure Linear issue workflows and issue-created resource events. type: tutorial -summary: Connect Linear and create conversation-linked issues without replacing Linear's hosted OAuth flow. +summary: Connect Linear for issue work, then optionally enable webhooks for issue.created event tasks. prerequisites: - /extend/ related: - /concepts/credentials-and-oauth/ + - /concepts/resource-subscriptions/ - /operate/security-hardening/ --- -The Linear plugin uses Linear's hosted MCP server so Slack users can find, create, update, comment on, and triage Linear issues from their own Linear account context. +Use the Linear plugin to find, create, update, comment on, and triage Linear issues from Slack. Each user connects their own Linear account through Linear's hosted MCP server. -Junior keeps the setup lightweight: the packaged plugin points at Linear's hosted remote MCP endpoint and lets Linear handle the user OAuth flow the first time a Linear tool is needed. +Optional webhooks let Junior publish `issue.created` resource events for subscriptions and event tasks. User MCP OAuth and webhook ingress stay separate. ## Install -Install the plugin package alongside `@sentry/junior`: - ```bash pnpm add @sentry/junior @sentry/junior-linear ``` -## Runtime setup - -Add the plugin to the set exported from `plugins.ts`: - ```ts title="plugins.ts" import { defineJuniorPlugins } from "@sentry/junior"; import { linearPlugin } from "@sentry/junior-linear"; @@ -33,18 +28,20 @@ import { linearPlugin } from "@sentry/junior-linear"; export const plugins = defineJuniorPlugins([linearPlugin()]); ``` +Register `linearPlugin()` so Junior loads the webhook route. + ## Auth model - No `LINEAR_API_KEY`, shared workspace token, or custom OAuth app is required for the default setup. - Each user completes Linear's MCP OAuth flow the first time Junior calls a Linear MCP tool on their behalf. - Junior sends the authorization link privately, then resumes the same thread automatically after the user authorizes. -- The packaged plugin is optimized for interactive user-driven work in Slack rather than unattended background automation. +- Webhooks use a separate Linear webhook secret. They do not use the user's MCP OAuth grant. Junior uses Linear's hosted MCP tools for reads and writes. When an issue is created through that path, Junior links it to the current conversation. ## Config -Set conversation config with `jr-rpc config set`, or define the same keys for every conversation with `createApp({ configDefaults })`. An explicit team or project in a request always wins. +Set conversation config with `jr-rpc config set`, or define the same keys for every conversation with `createApp({ configDefaults })`. Set deployment variables in the Junior environment, then redeploy. Explicit values in a request always win over conversation defaults. ### Conversation defaults @@ -72,30 +69,97 @@ Default project for issue creation when a request does not name one. Use it only +### Environment variables + +
+LINEAR_WEBHOOK_SECRET + +Webhook signing secret used to verify Linear issue webhooks. + +- **Define:** Set `LINEAR_WEBHOOK_SECRET` in the deployment environment +- **Required:** Yes for resource events; otherwise no +- **Environment override:** `LINEAR_WEBHOOK_SECRET` + +
+ ## What users can do - Look up Linear issues, teams, projects, and related workflow state. - Create a new Linear issue from Slack thread context. - Update issue fields such as state, assignee, title, or description. - Add comments that preserve relevant code, Sentry, or reproduction links already present in the conversation. +- Create temporary watches or durable event tasks for new Linear issues when webhooks are enabled. + +## Set up issue webhooks + +Create a Linear webhook in API settings for the workspace or team that should send issue events. + +1. Open Linear **Settings → API → Webhooks**. +2. Create a webhook for the `Issue` resource. +3. Set the webhook URL to: + +```text +https:///api/webhooks/linear +``` + +4. Copy the webhook signing secret into `LINEAR_WEBHOOK_SECRET`. +5. Redeploy Junior. + +Junior verifies the `Linear-Signature` header on every delivery. Resource events stay disabled until `LINEAR_WEBHOOK_SECRET` is set. + +Only workspace admins, or OAuth applications with the `admin` scope, can create or read Linear webhooks. + +## Resource subscriptions + +Set `LINEAR_WEBHOOK_SECRET` to enable resource subscriptions. See [Resource Subscriptions](/concepts/resource-subscriptions/) for the difference between temporary subscriptions and durable event tasks. + +### `issue` + +Subscribe to one issue with its Linear identifier, such as `SRE-123`. + +
+issue.created + +The issue was created. + +
+ +### `team` + +Subscribe to all new issues in a team with the Linear team key, such as `SRE`. + +
+issue.created + +An issue was created in the team. + +
+ +Create the subscription or event task before the issue arrives. Junior does not replay earlier webhooks. + +Identifiers are normalized to uppercase. Prefer team-scoped event tasks for monitor workflows that create many new issues. ## Verify -Confirm a real user can connect and complete a Linear workflow successfully: +**OAuth:** Ask Junior to create or update a real Linear issue, complete the private authorization flow, and confirm the issue key or URL returns in the same thread. + +**Webhooks:** Create an event task for a team key, then create a test issue in that team. + +## Security -1. Ask Junior to create or update a real Linear issue. -2. Complete the private OAuth flow when Junior prompts for it. -3. Confirm the thread resumes automatically and returns the Linear issue key or URL. -4. Open the issue in Linear and confirm the created or updated content matches the Slack request. -5. Open Junior App Home and confirm Linear appears under `Connected accounts`. +- Junior stores user MCP grants and does not include them in model input. +- Webhooks use the Linear webhook signing secret, not user MCP OAuth. +- Issue title, description, and other payload text are untrusted event content. ## Failure modes -- No auth prompt or no resume: retry the Linear request and complete the private authorization flow when prompted. -- Wrong team or project target: include the team name, project name, or existing Linear issue key explicitly in the Slack request. -- Duplicate or low-signal tickets: give Junior the core problem, impact, and any supporting URLs from the thread so it can create a grounded issue instead of a vague summary. -- Permission failures after connect: the user's Linear account may not have access to that team, project, or issue. Retry with a resource the user can access. +- **No auth prompt or no resume:** Retry the Linear request and complete the private authorization flow when prompted. +- **Wrong team or project target:** Include the team name, project name, or existing Linear issue key explicitly in the Slack request. +- **Duplicate or low-signal tickets:** Give Junior the core problem, impact, and any supporting URLs from the thread so it can create a grounded issue instead of a vague summary. +- **Permission failures after connect:** The user's Linear account may not have access to that team, project, or issue. Retry with a resource the user can access. +- **Webhooks are ignored:** Check `LINEAR_WEBHOOK_SECRET`, confirm the webhook points at `/api/webhooks/linear`, and confirm a matching subscription or event task exists. +- **Event task stays unavailable:** Resource events stay disabled until `LINEAR_WEBHOOK_SECRET` is set and Junior is redeployed. ## Next step -Review [Credentials & OAuth](/concepts/credentials-and-oauth/) and [Security Hardening](/operate/security-hardening/). +Review [Resource Subscriptions](/concepts/resource-subscriptions/) and [Security Hardening](/operate/security-hardening/). diff --git a/packages/junior-linear/README.md b/packages/junior-linear/README.md index 00cf931196..0234e9c0d7 100644 --- a/packages/junior-linear/README.md +++ b/packages/junior-linear/README.md @@ -21,6 +21,14 @@ This package does not require a shared `LINEAR_API_KEY` or a custom OAuth app fo Linear operations use Linear's hosted MCP tools directly. When an issue is created through that path, Junior links it to the current conversation. +To run watches or event tasks when Linear issues are created: + +1. Set `LINEAR_WEBHOOK_SECRET` to the Linear webhook signing secret. +2. Create a Linear webhook for the `Issue` resource at `https:///api/webhooks/linear`. +3. Redeploy Junior. + +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`. + Optional: set channel defaults when a Slack thread usually routes work to the same Linear destination: ```bash diff --git a/packages/junior-linear/package.json b/packages/junior-linear/package.json index 5f4e0ff9cf..c81aad6c66 100644 --- a/packages/junior-linear/package.json +++ b/packages/junior-linear/package.json @@ -23,9 +23,10 @@ ], "scripts": { "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", - "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tsup.config.ts", + "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts vitest.config.ts", "prepare": "pnpm run build", "prepack": "pnpm run build", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -36,6 +37,7 @@ "@types/node": "^25.9.1", "oxlint": "^1.66.0", "tsup": "^8.5.1", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.7" } } diff --git a/packages/junior-linear/src/plugin.ts b/packages/junior-linear/src/plugin.ts index c02a33500a..5c3823cbb5 100644 --- a/packages/junior-linear/src/plugin.ts +++ b/packages/junior-linear/src/plugin.ts @@ -4,6 +4,9 @@ import { type PluginRegistration, } from "@sentry/junior-plugin-api"; import { z } from "zod"; +import { LINEAR_ISSUE_EVENTS } from "./resource-events/issue.js"; +import { createLinearWebhookRoute } from "./webhooks/handler.js"; +import { linearWebhookSecret } from "./webhooks/secret.js"; const saveIssueResultSchema = z .object({ @@ -47,10 +50,30 @@ async function annotateCreatedIssue( export function linearPlugin(): PluginRegistration { return defineJuniorPlugin({ packageName: "@sentry/junior-linear", + resourceEvents: { + resourceTypes: [ + { + type: "issue", + supportedEvents: [...LINEAR_ISSUE_EVENTS], + suggestedEvents: [...LINEAR_ISSUE_EVENTS], + }, + { + type: "team", + supportedEvents: [...LINEAR_ISSUE_EVENTS], + suggestedEvents: [...LINEAR_ISSUE_EVENTS], + }, + ], + isEnabled: () => Boolean(linearWebhookSecret()), + normalizeIdentifier: (identifier) => identifier.toUpperCase(), + }, manifest: { configKeys: ["team", "project"], - description: "Linear issue tracking via hosted MCP server", + description: + "Linear issue tracking via hosted MCP server and issue webhooks", displayName: "Linear", + envVars: { + LINEAR_WEBHOOK_SECRET: {}, + }, mcp: { transport: "http", url: "https://mcp.linear.app/mcp", @@ -59,6 +82,14 @@ export function linearPlugin(): PluginRegistration { }, hooks: { afterMcpTool: annotateCreatedIssue, + routes(ctx) { + return [ + createLinearWebhookRoute({ + resourceEvents: ctx.resourceEvents, + webhookSecret: linearWebhookSecret, + }), + ]; + }, }, }); } diff --git a/packages/junior-linear/src/resource-events/issue.ts b/packages/junior-linear/src/resource-events/issue.ts new file mode 100644 index 0000000000..f9b283bdbb --- /dev/null +++ b/packages/junior-linear/src/resource-events/issue.ts @@ -0,0 +1,27 @@ +import type { SubscribableResource } from "@sentry/junior-plugin-api"; + +export const LINEAR_ISSUE_EVENTS = ["issue.created"] as const; + +/** Build the stable Linear issue identity shared by tools and webhooks. */ +export function linearIssueResource(input: { + identifier: string; +}): Pick { + const identifier = input.identifier.toUpperCase(); + return { + identifier, + label: `Linear issue ${identifier}`, + namespace: "linear", + }; +} + +/** Build the stable Linear team identity used for team-scoped events. */ +export function linearTeamResource(input: { + teamKey: string; +}): Pick { + const teamKey = input.teamKey.toUpperCase(); + return { + identifier: teamKey, + label: `Linear team ${teamKey}`, + namespace: "linear", + }; +} diff --git a/packages/junior-linear/src/webhooks/handler.ts b/packages/junior-linear/src/webhooks/handler.ts new file mode 100644 index 0000000000..6165375007 --- /dev/null +++ b/packages/junior-linear/src/webhooks/handler.ts @@ -0,0 +1,63 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { + PluginRoute, + ResourceEventPublisher, +} from "@sentry/junior-plugin-api"; +import { normalizeLinearResourceEvents } from "./resource-events.js"; + +function verifyLinearSignature( + body: string, + signature: string, + secret: string | undefined, +): boolean { + if (!secret || !/^[0-9a-f]{64}$/i.test(signature)) return false; + const actual = Buffer.from(signature.toLowerCase()); + const expected = Buffer.from( + createHmac("sha256", secret).update(body).digest("hex"), + ); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function parseJson(body: string): unknown { + try { + return JSON.parse(body); + } catch { + return undefined; + } +} + +/** Create the public, signed Linear resource-event webhook route. */ +export function createLinearWebhookRoute(args: { + resourceEvents: ResourceEventPublisher; + webhookSecret(): string | undefined; +}): PluginRoute { + return { + method: "POST", + path: "/api/webhooks/linear", + async handler(request) { + const rawBody = await request.text(); + const signature = request.headers.get("linear-signature") ?? ""; + if (!verifyLinearSignature(rawBody, signature, args.webhookSecret())) { + return new Response("Unauthorized", { status: 401 }); + } + const body = parseJson(rawBody); + if (body === undefined) { + return new Response("Malformed Linear webhook", { status: 400 }); + } + const delivery = request.headers.get("linear-delivery")?.trim(); + const linearEvent = request.headers.get("linear-event")?.trim(); + if (!delivery || !linearEvent) { + return new Response("Malformed Linear webhook headers", { + status: 400, + }); + } + const events = normalizeLinearResourceEvents({ body, linearEvent }); + for (const event of events) { + await args.resourceEvents.publish(event); + } + return new Response(events.length ? "Accepted" : "Ignored", { + status: 200, + }); + }, + }; +} diff --git a/packages/junior-linear/src/webhooks/resource-events.ts b/packages/junior-linear/src/webhooks/resource-events.ts new file mode 100644 index 0000000000..65ecb284d3 --- /dev/null +++ b/packages/junior-linear/src/webhooks/resource-events.ts @@ -0,0 +1,117 @@ +import type { ResourceEventInput } from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { + linearIssueResource, + linearTeamResource, +} from "../resource-events/issue.js"; + +const issueWebhookSchema = z + .object({ + action: z.string(), + createdAt: z.string().optional(), + data: z + .object({ + assignee: z + .object({ name: z.string().optional() }) + .passthrough() + .optional() + .nullable(), + description: z.string().optional().nullable(), + id: z.string().min(1), + identifier: z.string().trim().min(1), + labels: z + .array(z.object({ name: z.string().min(1) }).passthrough()) + .optional(), + priorityLabel: z.string().optional(), + project: z + .object({ name: z.string().optional() }) + .passthrough() + .optional() + .nullable(), + state: z + .object({ name: z.string().optional() }) + .passthrough() + .optional() + .nullable(), + team: z + .object({ key: z.string().trim().min(1) }) + .passthrough() + .optional(), + teamKey: z.string().trim().min(1).optional(), + title: z.string().optional(), + url: z.url(), + }) + .passthrough(), + type: z.string(), + url: z.url().optional(), + webhookTimestamp: z.number().finite().optional(), + }) + .passthrough(); + +function issueText( + issue: z.output["data"], +): string | undefined { + const labels = issue.labels?.map((label) => label.name).join(", "); + const parts = [ + issue.title ? `Title: ${issue.title}` : undefined, + issue.description ? `Description: ${issue.description}` : undefined, + issue.state?.name ? `State: ${issue.state.name}` : undefined, + issue.priorityLabel ? `Priority: ${issue.priorityLabel}` : undefined, + issue.project?.name ? `Project: ${issue.project.name}` : undefined, + labels ? `Labels: ${labels}` : undefined, + issue.assignee?.name ? `Assignee: ${issue.assignee.name}` : undefined, + `URL: ${issue.url}`, + ].filter((part): part is string => part !== undefined); + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function occurredAtMs(input: { + createdAt?: string; + webhookTimestamp?: number; +}): number { + const createdAt = input.createdAt ? Date.parse(input.createdAt) : Number.NaN; + if (Number.isFinite(createdAt)) return createdAt; + if (Number.isFinite(input.webhookTimestamp)) return input.webhookTimestamp!; + return Date.now(); +} + +/** Normalize one verified Linear webhook into issue- and team-scoped events. */ +export function normalizeLinearResourceEvents(input: { + body: unknown; + linearEvent: string; +}): ResourceEventInput[] { + if (input.linearEvent.toLowerCase() !== "issue") return []; + const parsed = issueWebhookSchema.safeParse(input.body); + if ( + !parsed.success || + parsed.data.action !== "create" || + parsed.data.type.toLowerCase() !== "issue" + ) { + return []; + } + + const issue = parsed.data.data; + const teamKey = issue.team?.key ?? issue.teamKey; + if (!teamKey) return []; + const issueResource = linearIssueResource({ identifier: issue.identifier }); + const teamResource = linearTeamResource({ teamKey }); + const eventType = "issue.created"; + const event = { + eventKey: `linear:${issue.id}:${eventType}`, + eventType, + occurredAtMs: occurredAtMs(parsed.data), + trustedSummary: `${issueResource.label} was created.`, + data: { + issueId: issue.id, + issueIdentifier: issueResource.identifier, + teamKey: teamResource.identifier, + url: issue.url, + }, + untrustedText: issueText(issue), + } satisfies Omit; + + return [ + { ...event, identifier: issueResource.identifier }, + { ...event, identifier: teamResource.identifier }, + ]; +} diff --git a/packages/junior-linear/src/webhooks/secret.ts b/packages/junior-linear/src/webhooks/secret.ts new file mode 100644 index 0000000000..71383f35fd --- /dev/null +++ b/packages/junior-linear/src/webhooks/secret.ts @@ -0,0 +1,4 @@ +/** Read the secret used to verify Linear webhook deliveries. */ +export function linearWebhookSecret(): string | undefined { + return process.env.LINEAR_WEBHOOK_SECRET?.trim() || undefined; +} diff --git a/packages/junior-linear/tests/webhook.test.ts b/packages/junior-linear/tests/webhook.test.ts new file mode 100644 index 0000000000..99a77baa85 --- /dev/null +++ b/packages/junior-linear/tests/webhook.test.ts @@ -0,0 +1,222 @@ +import { createHmac } from "node:crypto"; +import type { ResourceEventInput } from "@sentry/junior-plugin-api"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { linearPlugin } from "../src"; +import { createLinearWebhookRoute } from "../src/webhooks/handler"; +import { normalizeLinearResourceEvents } from "../src/webhooks/resource-events"; + +const SECRET = "linear-webhook-secret"; + +function issueBody(action = "create") { + return { + action, + actor: { id: "user-1", name: "Linear Orbit", type: "user" }, + createdAt: "2026-08-14T15:00:00.000Z", + data: { + assignee: { id: "user-2", name: "Bojan Oro" }, + description: "Disk xyz is approaching 80% capacity.", + id: "2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9", + identifier: "SRE-123", + labels: [{ id: "label-1", name: "datadog" }], + priorityLabel: "High", + project: { id: "project-1", name: "SRE monitors" }, + state: { id: "state-1", name: "Triage" }, + team: { id: "team-1", key: "SRE", name: "Site Reliability" }, + title: "Disk xyz is approaching capacity", + url: "https://linear.app/getsentry/issue/SRE-123/disk-xyz", + }, + type: "Issue", + url: "https://linear.app/getsentry/issue/SRE-123/disk-xyz", + webhookTimestamp: 1_786_719_600_000, + }; +} + +function signedRawRequest( + rawBody: string, + options: { + delivery?: string; + event?: string; + secret?: string; + } = {}, +): Request { + const signature = createHmac("sha256", options.secret ?? SECRET) + .update(rawBody) + .digest("hex"); + const headers: Record = { + "content-type": "application/json", + "linear-event": options.event ?? "Issue", + "linear-signature": signature, + }; + if (options.delivery !== "") { + headers["linear-delivery"] = options.delivery ?? "delivery-1"; + } + return new Request("https://example.test/api/webhooks/linear", { + body: rawBody, + headers, + method: "POST", + }); +} + +function signedRequest( + body: unknown, + options?: Parameters[1], +): Request { + return signedRawRequest(JSON.stringify(body), options); +} + +function routeFixture() { + const events: ResourceEventInput[] = []; + return { + events, + route: createLinearWebhookRoute({ + resourceEvents: { + async publish(event) { + events.push(event); + }, + }, + webhookSecret: () => SECRET, + }), + }; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Linear webhook resource events", () => { + it("normalizes a created issue for the issue and team", () => { + expect( + normalizeLinearResourceEvents({ + body: issueBody(), + linearEvent: "Issue", + }), + ).toEqual([ + { + data: { + issueId: "2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9", + issueIdentifier: "SRE-123", + teamKey: "SRE", + url: "https://linear.app/getsentry/issue/SRE-123/disk-xyz", + }, + eventKey: "linear:2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9:issue.created", + eventType: "issue.created", + identifier: "SRE-123", + occurredAtMs: Date.parse("2026-08-14T15:00:00.000Z"), + trustedSummary: "Linear issue SRE-123 was created.", + untrustedText: [ + "Title: Disk xyz is approaching capacity", + "Description: Disk xyz is approaching 80% capacity.", + "State: Triage", + "Priority: High", + "Project: SRE monitors", + "Labels: datadog", + "Assignee: Bojan Oro", + "URL: https://linear.app/getsentry/issue/SRE-123/disk-xyz", + ].join("\n"), + }, + expect.objectContaining({ + identifier: "SRE", + trustedSummary: "Linear issue SRE-123 was created.", + }), + ]); + }); + + it("publishes both events from a valid signed delivery", async () => { + const fixture = routeFixture(); + + const response = await fixture.route.handler(signedRequest(issueBody())); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("Accepted"); + expect(fixture.events.map((event) => event.identifier)).toEqual([ + "SRE-123", + "SRE", + ]); + }); + + it("uses a stable event key across webhook delivery retries", async () => { + const fixture = routeFixture(); + + await fixture.route.handler( + signedRequest(issueBody(), { delivery: "delivery-1" }), + ); + await fixture.route.handler( + signedRequest(issueBody(), { delivery: "delivery-2" }), + ); + + expect(new Set(fixture.events.map((event) => event.eventKey))).toEqual( + new Set(["linear:2174add1-f7c8-44e3-bbf3-2d60b5ea8bc9:issue.created"]), + ); + }); + + it("uses the trimmed environment secret for plugin ingress", async () => { + vi.stubEnv("LINEAR_WEBHOOK_SECRET", ` ${SECRET} `); + const publish = vi.fn(async () => {}); + const [route] = + linearPlugin().hooks?.routes?.({ + resourceEvents: { publish }, + } as never) ?? []; + + const response = await route?.handler(signedRequest(issueBody())); + + expect(response?.status).toBe(200); + expect(publish).toHaveBeenCalledTimes(2); + expect(linearPlugin().resourceEvents?.isEnabled?.()).toBe(true); + }); + + it("rejects a delivery whose signature does not match", async () => { + const fixture = routeFixture(); + + const response = await fixture.route.handler( + signedRequest(issueBody(), { secret: "wrong-secret" }), + ); + + expect(response.status).toBe(401); + expect(fixture.events).toEqual([]); + }); + + it("accepts but ignores unsupported resources and actions", async () => { + const fixture = routeFixture(); + + const unsupportedResource = await fixture.route.handler( + signedRequest(issueBody(), { event: "Comment" }), + ); + const unsupportedAction = await fixture.route.handler( + signedRequest(issueBody("update")), + ); + + expect(unsupportedResource.status).toBe(200); + expect(unsupportedAction.status).toBe(200); + expect(fixture.events).toEqual([]); + }); + + it("rejects malformed signed input", async () => { + const fixture = routeFixture(); + + const missingDelivery = await fixture.route.handler( + signedRequest(issueBody(), { delivery: "" }), + ); + const malformedJson = await fixture.route.handler( + signedRawRequest("not-json"), + ); + + expect(missingDelivery.status).toBe(400); + expect(malformedJson.status).toBe(400); + expect(fixture.events).toEqual([]); + }); + + it("propagates publisher failures so Linear can retry", async () => { + const route = createLinearWebhookRoute({ + resourceEvents: { + async publish() { + throw new Error("queue unavailable"); + }, + }, + webhookSecret: () => SECRET, + }); + + await expect(route.handler(signedRequest(issueBody()))).rejects.toThrow( + "queue unavailable", + ); + }); +}); diff --git a/packages/junior-linear/vitest.config.ts b/packages/junior-linear/vitest.config.ts new file mode 100644 index 0000000000..25bf25596b --- /dev/null +++ b/packages/junior-linear/vitest.config.ts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@sentry/junior-plugin-api": path.resolve( + __dirname, + "../junior-plugin-api/src/index.ts", + ), + }, + }, + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1efd684d4..085890b9f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,6 +522,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(tsx@4.22.3) packages/junior-maintenance: {}