From 96442914bfee9842faa97b1d45ee7b43317f7391 Mon Sep 17 00:00:00 2001 From: Lance Martin Date: Wed, 13 May 2026 08:45:31 -0700 Subject: [PATCH] feat(managed_agents): Linear stateless webhook bridge template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal TypeScript/Bun bridge connecting Linear's Agent Platform to Managed Agents via CMA outbound webhooks — no held SSE streams, no session-map DB (session.metadata carries linear_session_id + org_id). - setup/create-agent.ts: one-time agent + environment (claude-opus-4-7) - src/agent.ts: Linear @mention -> sessions.create + kickoff message - src/cma-webhook.ts: beta.webhooks.unwrap -> retrieve-then-filter -> createAgentActivity reply - src/oauth.ts: Linear OAuth (actor=app) + token store - README: diagram + 'bun install && claude' quickstart - CLAUDE.md: invoke /claude-api, read skill.md, offer extensions menu - skill.md: walkthrough, gotchas (workspace-scoped webhooks, actor=app, 10s ack, retrieve-then-filter), debugging Uses @anthropic-ai/sdk ^0.95.1 (beta.webhooks.unwrap). Proven e2e. --- managed_agents/linear/.env.example | 18 ++++ managed_agents/linear/.gitignore | 5 ++ managed_agents/linear/CLAUDE.md | 19 ++++ managed_agents/linear/README.md | 38 ++++++++ managed_agents/linear/package.json | 19 ++++ managed_agents/linear/setup/create-agent.ts | 21 +++++ managed_agents/linear/skill.md | 93 ++++++++++++++++++++ managed_agents/linear/src/agent.ts | 74 ++++++++++++++++ managed_agents/linear/src/cma-webhook.ts | 96 +++++++++++++++++++++ managed_agents/linear/src/main.ts | 59 +++++++++++++ managed_agents/linear/src/oauth.ts | 88 +++++++++++++++++++ managed_agents/linear/tsconfig.json | 10 +++ 12 files changed, 540 insertions(+) create mode 100644 managed_agents/linear/.env.example create mode 100644 managed_agents/linear/.gitignore create mode 100644 managed_agents/linear/CLAUDE.md create mode 100644 managed_agents/linear/README.md create mode 100644 managed_agents/linear/package.json create mode 100644 managed_agents/linear/setup/create-agent.ts create mode 100644 managed_agents/linear/skill.md create mode 100644 managed_agents/linear/src/agent.ts create mode 100644 managed_agents/linear/src/cma-webhook.ts create mode 100644 managed_agents/linear/src/main.ts create mode 100644 managed_agents/linear/src/oauth.ts create mode 100644 managed_agents/linear/tsconfig.json diff --git a/managed_agents/linear/.env.example b/managed_agents/linear/.env.example new file mode 100644 index 00000000..4bad77cd --- /dev/null +++ b/managed_agents/linear/.env.example @@ -0,0 +1,18 @@ +# Anthropic +ANTHROPIC_API_KEY=sk-ant-... + +# From `bun run setup` +CLAUDE_AGENT_ID=agent_... +CLAUDE_ENVIRONMENT_ID=env_... + +# From Anthropic Console → Webhooks → your endpoint +ANTHROPIC_WEBHOOK_SIGNING_KEY=whsec_... + +# From Linear → Administration → API → OAuth Applications → your app +LINEAR_CLIENT_ID=... +LINEAR_CLIENT_SECRET=... +LINEAR_WEBHOOK_SIGNING_SECRET=lin_wh_... + +# Public HTTPS URL for this server (ngrok/cloudflared locally, real host in prod) +PORT=3000 +BASE_URL=https://your-tunnel.example.com diff --git a/managed_agents/linear/.gitignore b/managed_agents/linear/.gitignore new file mode 100644 index 00000000..d59ae3cd --- /dev/null +++ b/managed_agents/linear/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +.env.local +.linear-tokens.json +bun.lock diff --git a/managed_agents/linear/CLAUDE.md b/managed_agents/linear/CLAUDE.md new file mode 100644 index 00000000..d5897a36 --- /dev/null +++ b/managed_agents/linear/CLAUDE.md @@ -0,0 +1,19 @@ +# Linear × Claude Managed Agents bridge + +Stateless webhook bridge: Linear `AgentSessionEvent` → CMA session (with routing metadata) → `session.status_idled` webhook → `createAgentActivity` reply. + +## When the user asks to set this up, get it working, or debug it + +1. **Invoke `/claude-api` first.** That skill loads the full Managed Agents API reference (agents, sessions, environments, events, webhooks, outcomes, multiagent, vaults, memory stores). Use it as the source of truth for any SDK call you write or edit — don't guess field names. +2. **Read `./skill.md`** and walk the user through it step by step. It has the ordered checklist, every gotcha (workspace-scoped webhooks, `actor=app` OAuth, 10s ack rule, retrieve-then-filter), and the debugging table. +3. **After the base bridge works, offer extensions.** Ask the user which (if any) they want, then edit `setup/create-agent.ts` and/or `src/agent.ts` accordingly: + - **GitHub repo** — mount a repo into the session container (`resources: [{type: "github_repository", ...}]` on `sessions.create`) + - **MCP tools** — e.g. Linear or GitHub MCP so the agent can act, not just reply (`mcp_servers` + `mcp_toolset` on the agent; credentials via a vault, `vault_ids` on the session) + - **Outcomes** — rubric-graded iterate loop (`user.define_outcome` event instead of `user.message`) + - **Multiagent** — coordinator + subagent roster (`multiagent: {type: "coordinator", agents: [...]}` on the agent) + - **Memory store** — cross-session persistence (`resources: [{type: "memory_store", ...}]`) + - **Custom tools** — host-side execution via `agent.custom_tool_use` / `user.custom_tool_result` + + Pull exact shapes from the `/claude-api` skill's `shared/managed-agents-*.md` docs. + +Run the server with `bun run dev`. One-time agent provisioning is `bun run setup`. diff --git a/managed_agents/linear/README.md b/managed_agents/linear/README.md new file mode 100644 index 00000000..5cd035b0 --- /dev/null +++ b/managed_agents/linear/README.md @@ -0,0 +1,38 @@ +# Linear × Claude Managed Agents + +`@mention` a Claude [Managed Agent](https://platform.claude.com/docs/en/managed-agents/overview) in a Linear issue and get the reply as a comment. + +``` +Linear @mention ──▶ /linear-webhook ──▶ sessions.create (+ metadata) ──▶ 200 + │ + Claude runs to idle on Anthropic infra + │ +/cma-webhook ◀── session.status_idled ◀──────────┘ + │ + └──▶ sessions.retrieve → read metadata → createAgentActivity +``` + +The CMA session's `metadata` (`linear_session_id`, `linear_org_id`) is the entire routing state. + +## Quickstart + +```bash +cd managed_agents/linear +bun install +claude +``` + +Then ask: **"walk me through setting this up."** Claude reads [`skill.md`](./skill.md) and drives the config — Linear OAuth app, Anthropic agent + webhook, env vars, `bun run dev` — in the order that actually works. + +## Files + +| | | +|---|---| +| `setup/create-agent.ts` | One-time: `agents.create` + `environments.create` | +| `src/main.ts` | Bun server, routes | +| `src/oauth.ts` | Linear OAuth (`actor=app`) + token store | +| `src/agent.ts` | `sessions.create` + `user.message` with routing metadata | +| `src/cma-webhook.ts` | `beta.webhooks.unwrap` → filter by metadata → post reply | +| `skill.md` | Setup walkthrough, gotchas, debugging | + +Requires `@anthropic-ai/sdk` ≥ 0.95.1. diff --git a/managed_agents/linear/package.json b/managed_agents/linear/package.json new file mode 100644 index 00000000..06891140 --- /dev/null +++ b/managed_agents/linear/package.json @@ -0,0 +1,19 @@ +{ + "name": "linear-cma-bridge", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/main.ts", + "start": "bun run src/main.ts", + "setup": "bun run setup/create-agent.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.95.1", + "@linear/sdk": "^81.0.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5" + } +} diff --git a/managed_agents/linear/setup/create-agent.ts b/managed_agents/linear/setup/create-agent.ts new file mode 100644 index 00000000..21c8c8e5 --- /dev/null +++ b/managed_agents/linear/setup/create-agent.ts @@ -0,0 +1,21 @@ +// One-time: create the Claude agent + environment. Copy the printed IDs into .env.local. +import Anthropic from "@anthropic-ai/sdk"; + +const anthropic = new Anthropic(); + +const env = await anthropic.beta.environments.create({ + name: `linear-bridge-${Date.now()}`, + config: { type: "cloud", networking: { type: "unrestricted" } }, +}); + +const agent = await anthropic.beta.agents.create({ + name: "Linear Assistant", + model: "claude-opus-4-7", + system: + "You are a helpful assistant embedded in Linear. Keep replies concise and actionable — they are posted as comments. Do not invent issue IDs, users, or project names.", + tools: [{ type: "agent_toolset_20260401", default_config: { enabled: true } }], +}); + +console.log("\nAdd to .env.local:"); +console.log(`CLAUDE_ENVIRONMENT_ID=${env.id}`); +console.log(`CLAUDE_AGENT_ID=${agent.id}`); diff --git a/managed_agents/linear/skill.md b/managed_agents/linear/skill.md new file mode 100644 index 00000000..486b6542 --- /dev/null +++ b/managed_agents/linear/skill.md @@ -0,0 +1,93 @@ +# Setup tips & tricks — Linear × CMA webhook bridge + +Things that aren't obvious from the docs and tend to cost debugging time. + +--- + +## Mental model + +### A webhook is "call me when something happens" + +It's just an HTTP POST a service sends to a URL you gave it, with a small JSON body describing an event. You register the URL once; the service calls it whenever the event fires. No polling, no held-open connections. + +### The bridge is mandatory (today) + +Linear's Agent Platform and CMA don't share a wire format or credentials. Something has to translate "Linear @mention" → "CMA user.message" on the way in, and "CMA idle" → "Linear comment" on the way out, while holding both sets of keys. That's the bridge. + +### Two webhooks, one bridge + +- **Linear → bridge** fires on @mention. Payload carries `agentSession.id`, `organizationId`, and issue context. +- **Anthropic → bridge** fires on session idle. Payload carries **only** the CMA session ID. + +Neither carries a "callback URL." Neither carries the agent's output. Both are just *signals* with IDs. + +### The webhook is a doorbell, not a delivery + +Anthropic's `session.status_idled` payload is deliberately thin — `{type, id}`. You always follow up with `sessions.retrieve(id)` (to get metadata) and `sessions.events.list(id)` (to get the output). Push the signal, pull the data. Retries stay cheap; data is never stale. + +### `metadata` is the entire routing state + +When the bridge creates the CMA session it sets `metadata: {linear_session_id, linear_org_id}`. When the idle webhook arrives later with only a session ID, the bridge retrieves the session, reads those keys back, and knows exactly where to reply — with nothing stored in the bridge itself. This is what makes it stateless. + +--- + +## Gotchas + +### Anthropic webhooks are workspace-scoped + +The endpoint you register in Console only receives events for sessions **in that same workspace**. If your `ANTHROPIC_API_KEY` belongs to workspace A but you registered the endpoint in workspace B, you get **zero deliveries, silently**. Check the Workspace column on your API key and make sure it matches the workspace picker in the Console's Webhooks page. + +### A workspace webhook fires for *every* session in the workspace + +Not just yours. If the Anthropic workspace is shared with other agents, scripts, or teammates, every `session.status_idled` in that workspace hits your endpoint. Your handler **must** filter: `sessions.retrieve(id)` → check for your `metadata` keys → return 204 for anything that isn't yours. Also catch 404/403 on `sessions.retrieve` — sessions created under other API keys in the same workspace aren't readable by yours. + +Corollaries: subscribe only to the event types you need (`session.status_idled`, `session.status_terminated`), not "All events"; and for production, consider a dedicated Anthropic workspace so there are no unrelated sessions to discard. + +### Linear OAuth apps are workspace-admin-only + +OAuth apps live at `linear.app//settings/api` — in the sidebar it's **Administration → API**, then the **OAuth Applications** section. If you're not an admin of your company workspace, spin up a free personal workspace for testing — takes two minutes and you won't install an experimental agent into production. + +When creating the app, the **Developer URL** field is required but cosmetic (it's just a link shown on the consent screen) — any real `https://` URL is fine. + +### `actor=app` is the load-bearing OAuth parameter + +`scope=app:assignable,app:mentionable` + `actor=app` is what creates an **app user** in the Linear workspace that shows up in the @-picker. A personal `LINEAR_API_KEY` won't do this — the bridge must post back *as the app*, via the OAuth token. + +### Linear's 10-second ack rule + +The bridge must post *some* `agentActivity` (a `{type: "thought"}` is enough) within 10 seconds of receiving the `AgentSessionEvent`, or Linear marks the session failed. Do this *before* creating the CMA session. + +### `event.id` is your idempotency key + +Anthropic retries failed deliveries with the **same** top-level `event.id`. Dedupe on it. Return 2xx once you've either handled it or decided to ignore it — anything else triggers a retry, and ~20 consecutive failures auto-disables your endpoint. + +### Signature header names + +The docs say `X-Webhook-Signature`; the wire uses `Webhook-Signature` / `Webhook-Id` / `Webhook-Timestamp` (Standard Webhooks spec). The SDK's `webhooks.unwrap()` handles this — only matters if you're verifying by hand. + +--- + +## Local dev checklist + +1. `ngrok http 3000` (or `cloudflared tunnel`) → note the public URL. Everything below uses it. +2. `bun run setup` → copy `CLAUDE_AGENT_ID` / `CLAUDE_ENVIRONMENT_ID` into `.env.local`. +3. Linear OAuth app (**Administration → API → OAuth Applications → Create new**): Developer URL = any real `https://` URL (cosmetic); callback `/oauth/callback`; webhook `/linear-webhook`, events = Agent session events → copy client ID/secret + webhook secret. +4. Anthropic Console → Webhooks: `/cma-webhook`, events = `session.status_idled` + `session.status_terminated` → copy `whsec_...`. **Same workspace as your API key.** +5. Fill `.env.local`, `bun run dev`. +6. Visit `/oauth/authorize` → approve → "Agent installed." +7. @mention it in an issue. + +### Debugging a silent failure + +- **"Thinking…" never appears** → Linear webhook isn't reaching you. Check the Linear app's webhook URL and that ngrok is up. +- **"Thinking…" appears but no reply** → check `curl localhost:4040/api/requests/http` (ngrok's request log). If no POST to `/cma-webhook`: workspace mismatch on the Anthropic side, or endpoint not saved. If POST arrives with 401: signing key mismatch. +- **Reply is empty** → `sessions.events.list` may be paginated; iterate if the agent produced many events. + +--- + +## Production notes + +- Replace ngrok with a real deploy (Cloudflare Workers, Fly, etc.). Nothing else changes. +- Replace the in-memory `seenEventIds` Set with Redis or a DB for multi-instance idempotency. +- Replace the `.linear-tokens.json` file with a real secret store. +- Narrow the Anthropic endpoint's event subscription to exactly what you handle. diff --git a/managed_agents/linear/src/agent.ts b/managed_agents/linear/src/agent.ts new file mode 100644 index 00000000..c8c6ac03 --- /dev/null +++ b/managed_agents/linear/src/agent.ts @@ -0,0 +1,74 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { LinearClient } from "@linear/sdk"; +import { getAccessToken } from "./oauth"; + +const anthropic = new Anthropic(); + +const CLAUDE_AGENT_ID = process.env.CLAUDE_AGENT_ID!; +const CLAUDE_ENVIRONMENT_ID = process.env.CLAUDE_ENVIRONMENT_ID!; + +interface AgentSessionEvent { + action: string; + agentSession: { + id: string; + issue?: { identifier: string; title: string; description?: string | null } | null; + comment?: { body: string } | null; + }; + agentActivity?: { content: { body?: string } } | null; + organizationId: string; + promptContext?: string | null; + previousComments?: Array<{ body: string }> | null; +} + +// Fire-and-forget: create the CMA session, attach routing metadata, send the +// prompt, return. The reply path is handled in cma-webhook.ts when Anthropic +// POSTs `session.status_idled`. +export async function kickoffAgentSession(event: AgentSessionEvent) { + const { agentSession, organizationId } = event; + + const accessToken = await getAccessToken(organizationId); + const linear = new LinearClient({ accessToken }); + + // Linear requires a first activity within 10s. + await linear.createAgentActivity({ + agentSessionId: agentSession.id, + content: { type: "thought", body: "Thinking..." }, + }); + + // Stash the Linear routing info on the CMA session. The idle webhook later + // delivers only a session ID; we read this metadata back to know where to + // post the reply. + const session = await anthropic.beta.sessions.create({ + agent: CLAUDE_AGENT_ID, + environment_id: CLAUDE_ENVIRONMENT_ID, + metadata: { + linear_session_id: agentSession.id, + linear_org_id: organizationId, + }, + }); + + await anthropic.beta.sessions.events.send(session.id, { + events: [{ type: "user.message", content: [{ type: "text", text: buildPrompt(event) }] }], + }); + + console.log(`[agent] kickoff linear=${agentSession.id} claude=${session.id}`); +} + +function buildPrompt(event: AgentSessionEvent): string { + if (event.promptContext) return event.promptContext; + + const parts: string[] = []; + const { agentSession, agentActivity, previousComments } = event; + + if (agentSession.issue) { + parts.push(`Issue: ${agentSession.issue.identifier} - ${agentSession.issue.title}`); + if (agentSession.issue.description) parts.push(`Description: ${agentSession.issue.description}`); + } + if (previousComments?.length) { + parts.push("Previous comments:\n" + previousComments.map((c) => `- ${c.body}`).join("\n")); + } + const msg = agentActivity?.content?.body ?? agentSession.comment?.body; + if (msg) parts.push(`User message: ${msg}`); + + return parts.join("\n\n") || "Hello! How can I help?"; +} diff --git a/managed_agents/linear/src/cma-webhook.ts b/managed_agents/linear/src/cma-webhook.ts new file mode 100644 index 00000000..0aa0130e --- /dev/null +++ b/managed_agents/linear/src/cma-webhook.ts @@ -0,0 +1,96 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { LinearClient } from "@linear/sdk"; +import { getAccessToken } from "./oauth"; + +const anthropic = new Anthropic(); + +// Dedupe retries (same event.id across retries). Swap for Redis/DB in prod. +const seenEventIds = new Set(); + +export async function handleCmaWebhook(req: Request): Promise { + const rawBody = await req.text(); + + // Verify HMAC + timestamp and parse. Reads ANTHROPIC_WEBHOOK_SIGNING_KEY + // from env. unwrap() needs a plain header map, not a fetch Headers object. + let event: Anthropic.Beta.BetaWebhookEvent; + try { + event = anthropic.beta.webhooks.unwrap(rawBody, { + headers: Object.fromEntries(req.headers), + }); + } catch (err) { + console.warn("[cma-webhook] signature verification failed"); + return new Response("bad signature", { status: 401 }); + } + + if (seenEventIds.has(event.id)) return new Response(null, { status: 204 }); + seenEventIds.add(event.id); + + if (event.data.type === "session.status_terminated") { + await postTerminationError(event.data.id); + return new Response(null, { status: 204 }); + } + + if (event.data.type !== "session.status_idled") { + return new Response(null, { status: 204 }); + } + + const claudeSessionId = event.data.id; + + // Workspace webhooks fire for EVERY session in the workspace. Fetch the + // session and filter by our metadata; ignore anything that isn't ours + // (including sessions our key can't read). + let session; + try { + session = await anthropic.beta.sessions.retrieve(claudeSessionId); + } catch { + return new Response(null, { status: 204 }); + } + + const linearSessionId = session.metadata?.linear_session_id; + const linearOrgId = session.metadata?.linear_org_id; + if (!linearSessionId || !linearOrgId) { + return new Response(null, { status: 204 }); + } + + // Pull the agent's reply text from the event history. Iterating the page + // object auto-paginates. + const parts: string[] = []; + for await (const e of anthropic.beta.sessions.events.list(claudeSessionId)) { + if (e.type === "agent.message") { + for (const block of e.content ?? []) { + if (block.type === "text") parts.push(block.text); + } + } + } + const responseText = parts.join("").trim(); + + if (!responseText) return new Response(null, { status: 204 }); + + const accessToken = await getAccessToken(linearOrgId); + const linear = new LinearClient({ accessToken }); + await linear.createAgentActivity({ + agentSessionId: linearSessionId, + content: { type: "response", body: responseText }, + }); + + console.log(`[cma-webhook] posted reply linear=${linearSessionId} claude=${claudeSessionId}`); + return new Response(null, { status: 204 }); +} + +async function postTerminationError(claudeSessionId: string) { + try { + const session = await anthropic.beta.sessions.retrieve(claudeSessionId); + const linearSessionId = session.metadata?.linear_session_id; + const linearOrgId = session.metadata?.linear_org_id; + if (!linearSessionId || !linearOrgId) return; + + const accessToken = await getAccessToken(linearOrgId); + const linear = new LinearClient({ accessToken }); + await linear.createAgentActivity({ + agentSessionId: linearSessionId, + content: { type: "error", body: "Agent session terminated unexpectedly." }, + }); + } catch (err) { + console.error("[cma-webhook] failed to post termination error:", err); + } +} diff --git a/managed_agents/linear/src/main.ts b/managed_agents/linear/src/main.ts new file mode 100644 index 00000000..fc338f94 --- /dev/null +++ b/managed_agents/linear/src/main.ts @@ -0,0 +1,59 @@ +import { LinearWebhookClient } from "@linear/sdk/webhooks"; +import { handleOAuthAuthorize, handleOAuthCallback } from "./oauth"; +import { kickoffAgentSession } from "./agent"; +import { handleCmaWebhook } from "./cma-webhook"; + +const PORT = Number(process.env.PORT) || 3000; +const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; + +for (const v of [ + "LINEAR_WEBHOOK_SIGNING_SECRET", + "ANTHROPIC_WEBHOOK_SIGNING_KEY", + "CLAUDE_AGENT_ID", + "CLAUDE_ENVIRONMENT_ID", +]) { + if (!process.env[v]) { + console.error(`FATAL: ${v} is required`); + process.exit(1); + } +} + +const linearHandler = new LinearWebhookClient( + process.env.LINEAR_WEBHOOK_SIGNING_SECRET!, +).createHandler(); + +linearHandler.on("AgentSessionEvent", (event) => { + console.log(`[linear] ${event.action} session=${event.agentSession.id}`); + kickoffAgentSession(event).catch((err) => + console.error("[linear] kickoff error:", err), + ); +}); + +Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + + if (url.pathname === "/" && req.method === "GET") { + return Response.json({ status: "ok" }); + } + if (url.pathname === "/oauth/authorize" && req.method === "GET") { + return handleOAuthAuthorize(); + } + if (url.pathname === "/oauth/callback" && req.method === "GET") { + return handleOAuthCallback(url); + } + if (url.pathname === "/linear-webhook" && req.method === "POST") { + return linearHandler(req); + } + if (url.pathname === "/cma-webhook" && req.method === "POST") { + return handleCmaWebhook(req); + } + return new Response("Not Found", { status: 404 }); + }, +}); + +console.log(`Bridge running at ${BASE_URL}`); +console.log(` Install agent: ${BASE_URL}/oauth/authorize`); +console.log(` Linear webhook: ${BASE_URL}/linear-webhook`); +console.log(` CMA webhook: ${BASE_URL}/cma-webhook`); diff --git a/managed_agents/linear/src/oauth.ts b/managed_agents/linear/src/oauth.ts new file mode 100644 index 00000000..57709238 --- /dev/null +++ b/managed_agents/linear/src/oauth.ts @@ -0,0 +1,88 @@ +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +const LINEAR_CLIENT_ID = process.env.LINEAR_CLIENT_ID!; +const LINEAR_CLIENT_SECRET = process.env.LINEAR_CLIENT_SECRET!; +const BASE_URL = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; +const REDIRECT_URI = `${BASE_URL}/oauth/callback`; + +type TokenEntry = { accessToken: string; refreshToken: string; expiresAt: number }; +const TOKEN_FILE = join(import.meta.dir, "..", ".linear-tokens.json"); + +function load(): Map { + try { + return new Map(Object.entries(JSON.parse(readFileSync(TOKEN_FILE, "utf-8")))); + } catch { + return new Map(); + } +} +function save(m: Map) { + writeFileSync(TOKEN_FILE, JSON.stringify(Object.fromEntries(m), null, 2)); +} +const tokens = load(); + +export function handleOAuthAuthorize(): Response { + const params = new URLSearchParams({ + client_id: LINEAR_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "read,write,app:assignable,app:mentionable", + actor: "app", + }); + return Response.redirect(`https://linear.app/oauth/authorize?${params}`); +} + +export async function handleOAuthCallback(url: URL): Promise { + const code = url.searchParams.get("code"); + if (!code) return new Response("Missing code", { status: 400 }); + + const tok = await exchange({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI }); + + const orgRes = await fetch("https://api.linear.app/graphql", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${tok.access_token}` }, + body: JSON.stringify({ query: "{ organization { id name } }" }), + }); + const org = ((await orgRes.json()) as any).data.organization; + + tokens.set(org.id, { + accessToken: tok.access_token, + refreshToken: tok.refresh_token, + expiresAt: Date.now() + tok.expires_in * 1000, + }); + save(tokens); + + console.log(`[oauth] installed in "${org.name}" (${org.id})`); + return new Response( + `

Agent installed in ${org.name}

You can now @mention it in Linear.

`, + { headers: { "Content-Type": "text/html" } }, + ); +} + +export async function getAccessToken(orgId: string): Promise { + const entry = tokens.get(orgId); + if (!entry) throw new Error(`No Linear token for org ${orgId}`); + + if (entry.expiresAt - Date.now() < 5 * 60 * 1000) { + const tok = await exchange({ grant_type: "refresh_token", refresh_token: entry.refreshToken }); + entry.accessToken = tok.access_token; + entry.refreshToken = tok.refresh_token; + entry.expiresAt = Date.now() + tok.expires_in * 1000; + save(tokens); + } + return entry.accessToken; +} + +async function exchange(params: Record) { + const res = await fetch("https://api.linear.app/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: LINEAR_CLIENT_ID, + client_secret: LINEAR_CLIENT_SECRET, + ...params, + }), + }); + if (!res.ok) throw new Error(`Linear OAuth: ${await res.text()}`); + return (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }; +} diff --git a/managed_agents/linear/tsconfig.json b/managed_agents/linear/tsconfig.json new file mode 100644 index 00000000..cc0202b0 --- /dev/null +++ b/managed_agents/linear/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "types": ["bun-types"] + } +}