Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions packages/docs/src/content/docs/concepts/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/extend/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
110 changes: 87 additions & 23 deletions packages/docs/src/content/docs/extend/linear-plugin.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,47 @@
---
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";

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

Expand Down Expand Up @@ -72,30 +69,97 @@ Default project for issue creation when a request does not name one. Use it only

</details>

### Environment variables

<details class="plugin-config">
<summary><code>LINEAR_WEBHOOK_SECRET</code></summary>

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`

</details>

## 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://<junior-host>/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`.

<details class="resource-event">
<summary><code>issue.created</code></summary>

The issue was created.

</details>

### `team`

Subscribe to all new issues in a team with the Linear team key, such as `SRE`.

<details class="resource-event">
<summary><code>issue.created</code></summary>

An issue was created in the team.

</details>

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/).
8 changes: 8 additions & 0 deletions packages/junior-linear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<junior-host>/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
Expand Down
6 changes: 4 additions & 2 deletions packages/junior-linear/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
}
33 changes: 32 additions & 1 deletion packages/junior-linear/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand All @@ -59,6 +82,14 @@ export function linearPlugin(): PluginRegistration {
},
hooks: {
afterMcpTool: annotateCreatedIssue,
routes(ctx) {
return [
createLinearWebhookRoute({
resourceEvents: ctx.resourceEvents,
webhookSecret: linearWebhookSecret,
}),
];
},
},
});
}
27 changes: 27 additions & 0 deletions packages/junior-linear/src/resource-events/issue.ts
Original file line number Diff line number Diff line change
@@ -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<SubscribableResource, "identifier" | "label" | "namespace"> {
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<SubscribableResource, "identifier" | "label" | "namespace"> {
const teamKey = input.teamKey.toUpperCase();
return {
identifier: teamKey,
label: `Linear team ${teamKey}`,
namespace: "linear",
};
}
63 changes: 63 additions & 0 deletions packages/junior-linear/src/webhooks/handler.ts
Original file line number Diff line number Diff line change
@@ -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,
});
},
};
}
Loading
Loading