import { all, run } from "../db"; import { DEFAULT_LINEAR_MAPPING } from "~/lib/integrations"; import { extractIssueKeys } from "./linear-keys"; import { isSuccess, outboundFetch } from "./outbound"; import type { OutboundResult } from "./outbound"; import { env } from "cloudflare:workers"; import { z } from "zod"; import { loadIntegration, openCredentials, parseConfig, updateCredentials } from "./store"; import type { IntegrationCredentials } from "./store"; import type { IntegrationRow } from "./store"; import type { DeliverInput } from "./deliver"; const LINEAR_GRAPHQL = "https://api.linear.app/graphql"; const MAX_KEYS_PER_EVENT = 5; const HTTP_UNAUTHORIZED = 401; const HTTP_TOO_MANY = 429; const STATE_RANK: Record = { triage: 0, backlog: 1, unstarted: 2, started: 3, completed: 4, canceled: 4, }; type LinearState = { id: string; name: string; type: string }; type LinearIssue = { id: string; identifier: string; state: LinearState; team: { states: { nodes: LinearState[] } } }; export async function linearGraphql( token: string, query: string, variables: Record, ): Promise<{ data: T | null; result: OutboundResult; graphqlError: string | null }> { const result = await outboundFetch(LINEAR_GRAPHQL, { headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }), }); if (result.status === null) return { data: null, result, graphqlError: null }; let parsed: { data?: T; errors?: Array<{ message: string; extensions?: { code?: string } }> }; try { parsed = JSON.parse(result.body); } catch { return { data: null, result: { ...result, error: result.error ?? "Linear returned invalid JSON" }, graphqlError: null, }; } if (parsed.errors?.length) { const { message, extensions } = parsed.errors[0]; const code = extensions?.code ?? ""; if (code === "AUTHENTICATION_ERROR" || result.status === HTTP_UNAUTHORIZED) { return { data: null, result: { ...result, status: HTTP_UNAUTHORIZED, error: message }, graphqlError: null }; } if (code === "RATELIMITED") { return { data: null, result: { ...result, status: HTTP_TOO_MANY, error: message }, graphqlError: null }; } return { data: null, result: { ...result, error: message }, graphqlError: message }; } if (!isSuccess(result)) return { data: null, result, graphqlError: null }; return { data: parsed.data ?? null, result, graphqlError: null }; } const ISSUE_QUERY = `query($id: String!) { issue(id: $id) { id identifier state { id name type } team { states { nodes { id name type } } } } }`; const ATTACH = `mutation($input: AttachmentCreateInput!) { attachmentCreate(input: $input) { success } }`; const MOVE = `mutation($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`; /** Picks the configured target state, refusing to move an issue backwards (e.g. Done back to In Progress). */ export function pickTargetState(issue: LinearIssue, targetName: string): LinearState | null { if (!targetName) return null; const target = issue.team.states.nodes.find((state) => state.name.toLowerCase() === targetName.toLowerCase()); if (!target || target.id === issue.state.id) return null; if ((STATE_RANK[target.type] ?? 0) < (STATE_RANK[issue.state.type] ?? 0)) return null; return target; } type Json = Record; function obj(value: unknown): Json { return value && typeof value === "object" && !Array.isArray(value) ? (value as Json) : {}; } function pullTransition(payload: Json): "opened" | "merged" | "closed" | null { const action = payload.action; if (action === "opened" || action === "reopened" || action === "ready_for_review") return "opened"; if (action === "closed") return obj(payload.pull_request).merged === true ? "merged" : "closed"; return null; } async function linkKeys(row: IntegrationRow, input: DeliverInput, number: number, keys: string[]): Promise { if (!input.repoId) return keys; for (const key of keys) { await run( `INSERT INTO linear_issue_links (integration_id, repo_id, pull_number, issue_key, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, row.id, input.repoId, number, key, Date.now(), ); } const linked = await all<{ issue_key: string }>( "SELECT issue_key FROM linear_issue_links WHERE integration_id = ? AND repo_id = ? AND pull_number = ? LIMIT ?", row.id, input.repoId, number, MAX_KEYS_PER_EVENT, ); return [...new Set([...keys, ...linked.map((link) => link.issue_key)])].slice(0, MAX_KEYS_PER_EVENT); } async function syncIssue( token: string, key: string, attachment: { url: string; title: string; subtitle: string }, targetState: string, ): Promise { const lookup = await linearGraphql<{ issue: LinearIssue | null }>(token, ISSUE_QUERY, { id: key }); if (lookup.graphqlError || (isSuccess(lookup.result) && !lookup.data?.issue)) { return { ...lookup.result, error: null }; } if (!lookup.data?.issue) return lookup.result; const issue = lookup.data.issue; const attached = await linearGraphql(token, ATTACH, { input: { issueId: issue.id, ...attachment } }); if (!isSuccess(attached.result)) return attached.result; const target = pickTargetState(issue, targetState); if (!target) return attached.result; return (await linearGraphql(token, MOVE, { id: issue.id, stateId: target.id })).result; } const linearRefresh = z.object({ access_token: z.string(), refresh_token: z.string().optional() }); async function refreshLinearToken(row: IntegrationRow, credentials: IntegrationCredentials): Promise { if (!credentials.refreshToken || !env.LINEAR_CLIENT_ID || !env.LINEAR_CLIENT_SECRET) return null; const result = await outboundFetch("https://api.linear.app/oauth/token", { headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: env.LINEAR_CLIENT_ID, client_secret: env.LINEAR_CLIENT_SECRET, }).toString(), }); if (!isSuccess(result)) return null; let body: unknown; try { body = JSON.parse(result.body); } catch { return null; } const parsed = linearRefresh.safeParse(body); if (!parsed.success) return null; await updateCredentials(row.id, { ...credentials, accessToken: parsed.data.access_token, refreshToken: parsed.data.refresh_token ?? credentials.refreshToken, }); return parsed.data.access_token; } /** Runs the event with the stored token, refreshing it once if Linear says it has expired. */ export async function handleLinearEvent(row: IntegrationRow, input: DeliverInput): Promise { const credentials = await openCredentials(row); if (!credentials.accessToken) { return { status: null, body: "", error: "Linear is not connected", durationMs: 0, permanent: true }; } const result = await runLinearEvent(row, input, credentials.accessToken); if (result.status !== HTTP_UNAUTHORIZED) return result; const latest = await loadIntegration(row.id); const current = latest ? await openCredentials(latest) : credentials; if (current.accessToken && current.accessToken !== credentials.accessToken) { return runLinearEvent(row, input, current.accessToken); } const refreshed = await refreshLinearToken(row, current); return refreshed ? runLinearEvent(row, input, refreshed) : result; } /** Links PRs (and pushed commits) to Linear issues by key, and moves issues per the configured state mapping. */ async function runLinearEvent(row: IntegrationRow, input: DeliverInput, token: string): Promise { const ok: OutboundResult = { status: 200, body: "", error: null, durationMs: 0 }; const config = parseConfig(row); const teams = config.linearTeamKeys ?? []; const mapping = { ...DEFAULT_LINEAR_MAPPING, ...config.linearMapping }; const started = Date.now(); let last = ok; if (input.event === "ping") return (await linearGraphql(token, "query { viewer { id } }", {})).result; if (input.event === "pull_request") { const transition = pullTransition(input.payload); if (!transition) return ok; const pull = obj(input.payload.pull_request); const number = Number(pull.number ?? input.payload.number); const found = extractIssueKeys([String(pull.title ?? ""), String(obj(pull.head).ref ?? "")], teams); const keys = await linkKeys(row, input, number, found); const subtitle = transition === "merged" ? "Merged" : transition === "closed" ? "Closed" : "Open"; const attachment = { url: String(pull.html_url ?? `${input.summary?.repoUrl ?? ""}/pull/${number}`), title: `PR #${number}${pull.title ? `: ${String(pull.title)}` : ""}`, subtitle: `${input.summary?.repo ?? "gitfor"} · ${subtitle}`, }; for (const key of keys) { last = await syncIssue(token, key, attachment, mapping[transition]); if (!isSuccess(last)) break; } } else if (input.event === "push") { const commits = Array.isArray(input.payload.commits) ? input.payload.commits.map(obj) : []; const ref = String(input.payload.ref ?? "").replace(/^refs\/heads\//, ""); for (const commit of commits.slice(0, MAX_KEYS_PER_EVENT)) { if (typeof commit.url !== "string" || !commit.url) continue; const keys = extractIssueKeys([String(commit.message ?? ""), ref], teams).slice(0, MAX_KEYS_PER_EVENT); const sha = String(commit.id ?? "").slice(0, 7); const attachment = { url: String(commit.url ?? ""), title: `Commit ${sha}: ${String(commit.message ?? "").split("\n", 1)[0]}`, subtitle: `${input.summary?.repo ?? "gitfor"} · ${ref}`, }; for (const key of keys) { last = await syncIssue(token, key, attachment, ""); if (!isSuccess(last)) break; } } } return { ...last, durationMs: Date.now() - started }; }