|
1 | 1 | /** |
2 | | - * Linear channel connector. |
| 2 | + * Linear channel connector (thin). |
3 | 3 | * |
4 | | - * Receives Linear webhooks (issue.create, issue.update, comment.create), |
5 | | - * fetches issue context, and posts results back as comments. |
6 | | - * |
7 | | - * Setup: In Linear → Settings → API → Webhooks, add: |
8 | | - * URL: https://your-gateway/webhook/linear |
9 | | - * Events: Issues (created, updated), Comments (created) |
| 4 | + * Translates Linear webhooks → AF runtime call. |
| 5 | + * Linear webhook events: issue.create, issue.update, comment.create. |
10 | 6 | */ |
11 | 7 |
|
12 | | -import type { ChannelConnector, NormalizedTask } from "../connector.js"; |
| 8 | +import type { ChannelConnector, InboundTask } from "../connector.js"; |
13 | 9 |
|
14 | 10 | export interface LinearConnectorConfig { |
15 | 11 | linearApiKey: string; |
16 | | - /** Map of Linear team key → AF agent ID. e.g. { "ENG": "af-agent-uuid" } */ |
| 12 | + /** Team key → AF agent ID. e.g. { "ENG": "af-uuid" } */ |
17 | 13 | agentMapping: Record<string, string>; |
18 | | - /** Optional: AF stream URL override per agent. */ |
19 | | - streamUrlMapping?: Record<string, string>; |
20 | 14 | } |
21 | 15 |
|
22 | | -// Minimal Linear GraphQL client |
23 | | -async function linearQuery( |
24 | | - apiKey: string, |
25 | | - query: string, |
26 | | - variables?: Record<string, unknown>, |
27 | | -): Promise<unknown> { |
| 16 | +async function linearGql(apiKey: string, query: string, variables?: Record<string, unknown>): Promise<unknown> { |
28 | 17 | const resp = await fetch("https://api.linear.app/graphql", { |
29 | 18 | method: "POST", |
30 | | - headers: { |
31 | | - "Content-Type": "application/json", |
32 | | - Authorization: apiKey, |
33 | | - }, |
| 19 | + headers: { "Content-Type": "application/json", Authorization: apiKey }, |
34 | 20 | body: JSON.stringify({ query, variables }), |
35 | 21 | }); |
36 | | - if (!resp.ok) throw new Error(`Linear API failed (${resp.status})`); |
| 22 | + if (!resp.ok) throw new Error(`Linear API ${resp.status}`); |
37 | 23 | const result = (await resp.json()) as { data?: unknown; errors?: unknown[] }; |
38 | | - if (result.errors) throw new Error(`Linear GraphQL: ${JSON.stringify(result.errors)}`); |
| 24 | + if (result.errors) throw new Error(JSON.stringify(result.errors)); |
39 | 25 | return result.data; |
40 | 26 | } |
41 | 27 |
|
42 | | -async function linearGetIssue(apiKey: string, issueId: string): Promise<Record<string, unknown>> { |
43 | | - const data = (await linearQuery(apiKey, ` |
44 | | - query($id: String!) { |
45 | | - issue(id: $id) { |
46 | | - id identifier title description priority priorityLabel |
47 | | - state { name } |
48 | | - team { key name } |
49 | | - assignee { name } |
50 | | - labels { nodes { name } } |
51 | | - comments { nodes { body user { name } createdAt } } |
52 | | - } |
53 | | - } |
54 | | - `, { id: issueId })) as { issue: Record<string, unknown> }; |
55 | | - return data.issue; |
56 | | -} |
57 | | - |
58 | | -async function linearAddComment(apiKey: string, issueId: string, body: string): Promise<void> { |
59 | | - await linearQuery(apiKey, ` |
60 | | - mutation($issueId: String!, $body: String!) { |
61 | | - commentCreate(input: { issueId: $issueId, body: $body }) { |
62 | | - success |
63 | | - } |
64 | | - } |
65 | | - `, { issueId, body }); |
66 | | -} |
67 | | - |
68 | 28 | export class LinearConnector implements ChannelConnector { |
69 | 29 | readonly name = "linear"; |
70 | | - readonly displayName = "Linear"; |
71 | 30 |
|
72 | 31 | constructor(private config: LinearConnectorConfig) {} |
73 | 32 |
|
74 | 33 | async parseWebhook( |
75 | 34 | _headers: Record<string, string | string[] | undefined>, |
76 | 35 | body: string, |
77 | | - ): Promise<NormalizedTask | null> { |
| 36 | + ): Promise<InboundTask | null> { |
78 | 37 | const payload = JSON.parse(body) as { |
79 | 38 | action: string; |
80 | 39 | type: string; |
81 | | - data: { |
82 | | - id: string; |
83 | | - title?: string; |
84 | | - description?: string; |
85 | | - identifier?: string; |
86 | | - teamId?: string; |
87 | | - issueId?: string; // for comments |
88 | | - body?: string; // for comments |
89 | | - [key: string]: unknown; |
90 | | - }; |
91 | | - url?: string; |
| 40 | + data: Record<string, unknown>; |
92 | 41 | }; |
93 | 42 |
|
94 | | - // Only handle issue creates/updates and comment creates |
95 | 43 | const isIssue = payload.type === "Issue" && ["create", "update"].includes(payload.action); |
96 | 44 | const isComment = payload.type === "Comment" && payload.action === "create"; |
97 | 45 | if (!isIssue && !isComment) return null; |
98 | 46 |
|
99 | | - const issueId = isComment ? payload.data.issueId! : payload.data.id; |
| 47 | + const issueId = isComment ? payload.data.issueId as string : payload.data.id as string; |
| 48 | + |
| 49 | + // Fetch issue for context |
| 50 | + const data = (await linearGql(this.config.linearApiKey, ` |
| 51 | + query($id: String!) { |
| 52 | + issue(id: $id) { |
| 53 | + id identifier title description priorityLabel |
| 54 | + state { name } |
| 55 | + team { key } |
| 56 | + } |
| 57 | + } |
| 58 | + `, { id: issueId })) as { issue: Record<string, unknown> }; |
100 | 59 |
|
101 | | - // Fetch full issue details |
102 | | - const issue = await linearGetIssue(this.config.linearApiKey, issueId); |
| 60 | + const issue = data.issue; |
103 | 61 | const team = issue.team as Record<string, unknown> | undefined; |
104 | 62 | const teamKey = (team?.key as string) ?? ""; |
105 | | - |
106 | | - // Find AF agent for this team |
107 | 63 | const afAgentId = this.config.agentMapping[teamKey]; |
108 | | - if (!afAgentId) return null; // No agent mapped for this team — skip |
| 64 | + if (!afAgentId) return null; // No agent for this team |
109 | 65 |
|
110 | 66 | const state = issue.state as Record<string, unknown> | undefined; |
111 | | - const labels = issue.labels as { nodes: Array<{ name: string }> } | undefined; |
112 | | - const comments = issue.comments as { nodes: Array<Record<string, unknown>> } | undefined; |
113 | | - |
114 | | - // Build message |
115 | | - const parts: string[] = []; |
116 | | - parts.push("You have received a task from Linear.\n"); |
117 | | - parts.push(`## Task: ${issue.identifier} — ${issue.title}`); |
118 | | - parts.push(`- **Priority:** ${issue.priorityLabel ?? "medium"}`); |
119 | | - parts.push(`- **Status:** ${state?.name ?? "unknown"}`); |
120 | | - if (labels?.nodes?.length) { |
121 | | - parts.push(`- **Labels:** ${labels.nodes.map((l) => l.name).join(", ")}`); |
122 | | - } |
123 | | - if (issue.description) { |
124 | | - parts.push(`\n### Description\n${issue.description}`); |
125 | | - } |
126 | | - if (comments?.nodes?.length) { |
127 | | - parts.push("\n### Recent Comments"); |
128 | | - for (const c of comments.nodes.slice(-5)) { |
129 | | - const user = c.user as Record<string, unknown> | undefined; |
130 | | - parts.push(`- **${user?.name ?? "Unknown"}:** ${c.body}`); |
131 | | - } |
132 | | - } |
133 | | - if (isComment && payload.data.body) { |
134 | | - parts.push(`\n### New Comment (trigger)\n${payload.data.body}`); |
135 | | - } |
136 | | - parts.push("\n## Instructions\nComplete this task. Provide a clear summary of your work."); |
| 67 | + const parts = [ |
| 68 | + `Task: ${issue.identifier} — ${issue.title}`, |
| 69 | + `Priority: ${issue.priorityLabel ?? "medium"} | Status: ${state?.name ?? "unknown"}`, |
| 70 | + ]; |
| 71 | + if (issue.description) parts.push(`Description: ${issue.description}`); |
| 72 | + if (isComment && payload.data.body) parts.push(`New comment: ${payload.data.body}`); |
137 | 73 |
|
138 | 74 | return { |
139 | | - threadId: issueId, // reuse Linear issue ID as thread for continuity |
140 | | - taskIdentifier: (issue.identifier as string) ?? issueId, |
141 | | - message: parts.join("\n"), |
142 | 75 | afAgentId, |
143 | | - afStreamUrl: this.config.streamUrlMapping?.[afAgentId], |
144 | | - source: { |
145 | | - channel: "linear", |
146 | | - chatId: teamKey, |
147 | | - userName: (issue.assignee as Record<string, unknown>)?.name as string | undefined, |
148 | | - }, |
149 | | - platformContext: { issueId, teamKey }, |
| 76 | + message: parts.join("\n"), |
| 77 | + threadId: issueId, |
| 78 | + label: (issue.identifier as string) ?? issueId, |
| 79 | + replyContext: { issueId, teamKey }, |
150 | 80 | }; |
151 | 81 | } |
152 | 82 |
|
153 | | - async postResult(task: NormalizedTask, resultText: string): Promise<void> { |
154 | | - const issueId = task.platformContext.issueId as string; |
| 83 | + async postResult(task: InboundTask, resultText: string): Promise<void> { |
| 84 | + const issueId = task.replyContext.issueId as string; |
155 | 85 | if (issueId && resultText) { |
156 | | - await linearAddComment( |
157 | | - this.config.linearApiKey, |
158 | | - issueId, |
159 | | - `**Agent Response:**\n\n${resultText}`, |
160 | | - ); |
| 86 | + await linearGql(this.config.linearApiKey, ` |
| 87 | + mutation($issueId: String!, $body: String!) { |
| 88 | + commentCreate(input: { issueId: $issueId, body: $body }) { success } |
| 89 | + } |
| 90 | + `, { issueId, body: `**Agent Response:**\n\n${resultText}` }); |
161 | 91 | } |
162 | 92 | } |
163 | 93 | } |
0 commit comments