@mastra/factory 0.2.2-alpha.0 → 0.2.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/auth.d.ts +2 -2
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +401 -314
  5. package/dist/auth.js.map +1 -1
  6. package/dist/capabilities/connection.js +1 -1
  7. package/dist/capabilities/intake.js +1 -1
  8. package/dist/capabilities/version-control.js +1 -1
  9. package/dist/factory.d.ts.map +1 -1
  10. package/dist/factory.js +441 -39455
  11. package/dist/factory.js.map +1 -1
  12. package/dist/index.js +2 -39466
  13. package/dist/integrations/base.js +1 -1
  14. package/dist/integrations/github/config.js +36 -22
  15. package/dist/integrations/github/config.js.map +1 -1
  16. package/dist/integrations/github/factory-session.js +33 -37
  17. package/dist/integrations/github/factory-session.js.map +1 -1
  18. package/dist/integrations/github/integration.js +1037 -3624
  19. package/dist/integrations/github/integration.js.map +1 -1
  20. package/dist/integrations/github/issue-triage.js +71 -71
  21. package/dist/integrations/github/issue-triage.js.map +1 -1
  22. package/dist/integrations/github/pat.js +47 -36
  23. package/dist/integrations/github/pat.js.map +1 -1
  24. package/dist/integrations/github/provenance.js +72 -101
  25. package/dist/integrations/github/provenance.js.map +1 -1
  26. package/dist/integrations/github/routes.d.ts.map +1 -1
  27. package/dist/integrations/github/routes.js +1272 -1833
  28. package/dist/integrations/github/routes.js.map +1 -1
  29. package/dist/integrations/github/rules.js +203 -471
  30. package/dist/integrations/github/rules.js.map +1 -1
  31. package/dist/integrations/github/sandbox.d.ts +2 -0
  32. package/dist/integrations/github/sandbox.d.ts.map +1 -1
  33. package/dist/integrations/github/sandbox.js +401 -326
  34. package/dist/integrations/github/sandbox.js.map +1 -1
  35. package/dist/integrations/github/session-subscriptions.js +165 -223
  36. package/dist/integrations/github/session-subscriptions.js.map +1 -1
  37. package/dist/integrations/github/subscriptions.js +43 -51
  38. package/dist/integrations/github/subscriptions.js.map +1 -1
  39. package/dist/integrations/github/token-refresh.js +15 -18
  40. package/dist/integrations/github/token-refresh.js.map +1 -1
  41. package/dist/integrations/github/webhook.js +359 -343
  42. package/dist/integrations/github/webhook.js.map +1 -1
  43. package/dist/integrations/linear/agent-tools.js +77 -100
  44. package/dist/integrations/linear/agent-tools.js.map +1 -1
  45. package/dist/integrations/linear/integration.js +611 -1309
  46. package/dist/integrations/linear/integration.js.map +1 -1
  47. package/dist/integrations/linear/routes.js +227 -230
  48. package/dist/integrations/linear/routes.js.map +1 -1
  49. package/dist/integrations/linear/rules.js +126 -346
  50. package/dist/integrations/linear/rules.js.map +1 -1
  51. package/dist/integrations/linear/storage.js +1 -1
  52. package/dist/integrations/platform/api-client.js +131 -135
  53. package/dist/integrations/platform/api-client.js.map +1 -1
  54. package/dist/integrations/platform/github/event-worker.js +275 -573
  55. package/dist/integrations/platform/github/event-worker.js.map +1 -1
  56. package/dist/integrations/platform/github/integration.d.ts.map +1 -1
  57. package/dist/integrations/platform/github/integration.js +845 -3942
  58. package/dist/integrations/platform/github/integration.js.map +1 -1
  59. package/dist/integrations/platform/linear/integration.js +457 -1309
  60. package/dist/integrations/platform/linear/integration.js.map +1 -1
  61. package/dist/integrations/workos/integration.js +80 -85
  62. package/dist/integrations/workos/integration.js.map +1 -1
  63. package/dist/routes/config.d.ts.map +1 -1
  64. package/dist/routes/config.js +933 -915
  65. package/dist/routes/config.js.map +1 -1
  66. package/dist/routes/custom-provider-source.js +110 -93
  67. package/dist/routes/custom-provider-source.js.map +1 -1
  68. package/dist/routes/fs.js +401 -349
  69. package/dist/routes/fs.js.map +1 -1
  70. package/dist/routes/intake.js +165 -165
  71. package/dist/routes/intake.js.map +1 -1
  72. package/dist/routes/oauth.js +447 -25166
  73. package/dist/routes/oauth.js.map +1 -1
  74. package/dist/routes/projects.js +437 -376
  75. package/dist/routes/projects.js.map +1 -1
  76. package/dist/routes/provider-credentials.js +76 -58
  77. package/dist/routes/provider-credentials.js.map +1 -1
  78. package/dist/routes/route.js +12 -8
  79. package/dist/routes/route.js.map +1 -1
  80. package/dist/routes/skills.js +160 -173
  81. package/dist/routes/skills.js.map +1 -1
  82. package/dist/routes/surface.js +250 -28618
  83. package/dist/routes/surface.js.map +1 -1
  84. package/dist/routes/tenant-credentials.js +141 -166
  85. package/dist/routes/tenant-credentials.js.map +1 -1
  86. package/dist/routes/work-items.js +654 -862
  87. package/dist/routes/work-items.js.map +1 -1
  88. package/dist/rules/binding-context.js +23 -35
  89. package/dist/rules/binding-context.js.map +1 -1
  90. package/dist/rules/defaults.js +200 -309
  91. package/dist/rules/defaults.js.map +1 -1
  92. package/dist/rules/dispatcher.d.ts.map +1 -1
  93. package/dist/rules/dispatcher.js +438 -677
  94. package/dist/rules/dispatcher.js.map +1 -1
  95. package/dist/rules/index.js +5 -599
  96. package/dist/rules/processor.js +344 -596
  97. package/dist/rules/processor.js.map +1 -1
  98. package/dist/rules/resolve.js +21 -18
  99. package/dist/rules/resolve.js.map +1 -1
  100. package/dist/rules/start-coordinator.js +168 -157
  101. package/dist/rules/start-coordinator.js.map +1 -1
  102. package/dist/rules/tools.js +50 -85
  103. package/dist/rules/tools.js.map +1 -1
  104. package/dist/rules/transition-service.js +183 -454
  105. package/dist/rules/transition-service.js.map +1 -1
  106. package/dist/rules/types.js +33 -29
  107. package/dist/rules/types.js.map +1 -1
  108. package/dist/rules/validation.js +266 -281
  109. package/dist/rules/validation.js.map +1 -1
  110. package/dist/sandbox/fleet.js +272 -250
  111. package/dist/sandbox/fleet.js.map +1 -1
  112. package/dist/sandbox/reattach.js +13 -6
  113. package/dist/sandbox/reattach.js.map +1 -1
  114. package/dist/server-error.js +12 -16
  115. package/dist/server-error.js.map +1 -1
  116. package/dist/skills/service.js +30 -35
  117. package/dist/skills/service.js.map +1 -1
  118. package/dist/spa-static.d.ts.map +1 -1
  119. package/dist/spa-static.js +91 -64
  120. package/dist/spa-static.js.map +1 -1
  121. package/dist/state-signing.js +73 -44
  122. package/dist/state-signing.js.map +1 -1
  123. package/dist/storage/domains/audit/agent-audit.js +60 -59
  124. package/dist/storage/domains/audit/agent-audit.js.map +1 -1
  125. package/dist/storage/domains/audit/base.js +169 -112
  126. package/dist/storage/domains/audit/base.js.map +1 -1
  127. package/dist/storage/domains/audit/domain.js +155 -153
  128. package/dist/storage/domains/audit/domain.js.map +1 -1
  129. package/dist/storage/domains/credentials/base.js +259 -243
  130. package/dist/storage/domains/credentials/base.js.map +1 -1
  131. package/dist/storage/domains/custom-providers/base.js +138 -143
  132. package/dist/storage/domains/custom-providers/base.js.map +1 -1
  133. package/dist/storage/domains/intake/base.js +75 -57
  134. package/dist/storage/domains/intake/base.js.map +1 -1
  135. package/dist/storage/domains/integrations/base.js +310 -245
  136. package/dist/storage/domains/integrations/base.js.map +1 -1
  137. package/dist/storage/domains/memory-settings/base.js +117 -102
  138. package/dist/storage/domains/memory-settings/base.js.map +1 -1
  139. package/dist/storage/domains/model-packs/base.js +89 -88
  140. package/dist/storage/domains/model-packs/base.js.map +1 -1
  141. package/dist/storage/domains/projects/base.js +97 -92
  142. package/dist/storage/domains/projects/base.js.map +1 -1
  143. package/dist/storage/domains/queue-health/base.js +111 -76
  144. package/dist/storage/domains/queue-health/base.js.map +1 -1
  145. package/dist/storage/domains/source-control/base.d.ts +9 -0
  146. package/dist/storage/domains/source-control/base.d.ts.map +1 -1
  147. package/dist/storage/domains/source-control/base.js +777 -702
  148. package/dist/storage/domains/source-control/base.js.map +1 -1
  149. package/dist/storage/domains/source-control/inmemory.d.ts +4 -0
  150. package/dist/storage/domains/source-control/inmemory.d.ts.map +1 -1
  151. package/dist/storage/domains/source-control/inmemory.js +322 -344
  152. package/dist/storage/domains/source-control/inmemory.js.map +1 -1
  153. package/dist/storage/domains/work-items/base.d.ts.map +1 -1
  154. package/dist/storage/domains/work-items/base.js +1266 -1132
  155. package/dist/storage/domains/work-items/base.js.map +1 -1
  156. package/dist/storage/domains/work-items/metrics.js +202 -184
  157. package/dist/storage/domains/work-items/metrics.js.map +1 -1
  158. package/dist/timing.d.ts +15 -0
  159. package/dist/timing.d.ts.map +1 -0
  160. package/dist/timing.js +34 -0
  161. package/dist/timing.js.map +1 -0
  162. package/dist/workspace.d.ts +2 -3
  163. package/dist/workspace.d.ts.map +1 -1
  164. package/dist/workspace.js +236 -438
  165. package/dist/workspace.js.map +1 -1
  166. package/factory-skills/factory-review/SKILL.md +9 -2
  167. package/package.json +9 -9
  168. package/dist/capabilities/connection.js.map +0 -1
  169. package/dist/capabilities/intake.js.map +0 -1
  170. package/dist/capabilities/version-control.js.map +0 -1
  171. package/dist/index.js.map +0 -1
  172. package/dist/integrations/base.js.map +0 -1
  173. package/dist/integrations/linear/storage.js.map +0 -1
  174. package/dist/rules/index.js.map +0 -1
@@ -1,1144 +1,462 @@
1
- // src/integrations/linear/agent-tools.ts
2
- import { createTool } from "@mastra/core/tools";
3
- import { z } from "zod";
4
- function createLinearGetIssueTool(linear, orgId) {
5
- return createTool({
6
- id: "linear_get_issue",
7
- description: "Fetch a Linear issue's full details \u2014 title, description, state, assignee, labels, priority, and discussion comments. Use this whenever you're working on a Linear issue (e.g. ENG-123) to get its complete context.",
8
- inputSchema: z.object({
9
- issue: z.string().min(1).describe('The Linear issue identifier (e.g. "ENG-123") or issue UUID.')
10
- }),
11
- execute: async ({ issue }) => {
12
- const connection = await linear.loadConnection(orgId);
13
- if (!connection) {
14
- return { error: "Linear is not connected for this repository. Connect Linear in Settings to fetch issues." };
15
- }
16
- try {
17
- const accessToken = await linear.getFreshAccessToken(connection);
18
- const detail = await linear.intake.getIssue({
19
- connection: { type: "oauth", accessToken },
20
- issueId: issue.trim()
21
- });
22
- if (!detail) {
23
- return { error: `Linear issue "${issue}" was not found in this workspace.` };
24
- }
25
- return detail;
26
- } catch (err) {
27
- if (err instanceof LinearReauthRequiredError) {
28
- return { error: err.message };
29
- }
30
- return { error: `Failed to fetch Linear issue: ${err instanceof Error ? err.message : String(err)}` };
31
- }
32
- }
33
- });
34
- }
35
- function createLinearCommentTool(linear, orgId) {
36
- return createTool({
37
- id: "linear_create_comment",
38
- description: "Post a comment on a Linear issue (e.g. to report investigation findings, link a PR, or ask a clarifying question). The comment is posted as the connected Linear integration, so make clear it comes from the agent.",
39
- inputSchema: z.object({
40
- issue: z.string().min(1).describe('The Linear issue identifier (e.g. "ENG-123") or issue UUID.'),
41
- body: z.string().min(1).describe("The comment body, as Linear-flavored markdown.")
42
- }),
43
- execute: async ({ issue, body }) => {
44
- const connection = await linear.loadConnection(orgId);
45
- if (!connection) {
46
- return { error: "Linear is not connected for this repository. Connect Linear in Settings to post comments." };
47
- }
48
- if (!linear.canPostComments(connection)) {
49
- return {
50
- error: "The Linear connection does not have comment permissions. Reconnect Linear in Settings to grant them."
51
- };
52
- }
53
- try {
54
- const accessToken = await linear.getFreshAccessToken(connection);
55
- const comment = await linear.intake.createComment({
56
- connection: { type: "oauth", accessToken },
57
- issueId: issue.trim(),
58
- body
59
- });
60
- if (!comment) {
61
- return { error: `Linear issue "${issue}" was not found in this workspace.` };
62
- }
63
- return { posted: true, url: comment.url };
64
- } catch (err) {
65
- if (err instanceof LinearReauthRequiredError) {
66
- return { error: err.message };
67
- }
68
- return { error: `Failed to post Linear comment: ${err instanceof Error ? err.message : String(err)}` };
69
- }
70
- }
71
- });
72
- }
73
- async function buildLinearAgentTools({
74
- requestContext,
75
- linear
76
- }) {
77
- if (!linear.authEnabled) return {};
78
- const ctx = requestContext.get("controller");
79
- const resourceId = ctx?.resourceId;
80
- if (!resourceId) return {};
81
- const orgId = await linear.resolveOrgId(resourceId);
82
- if (!orgId) return {};
83
- const check = await linear.checkConnection(orgId);
84
- if (!check.connected) return {};
85
- return {
86
- linear_get_issue: createLinearGetIssueTool(linear, orgId),
87
- // Only offered when the granted OAuth scope allows posting comments —
88
- // connections made before `comments:create` was requested are read-only
89
- // until the org reconnects Linear.
90
- ...check.canComment ? { linear_create_comment: createLinearCommentTool(linear, orgId) } : {}
91
- };
92
- }
93
-
94
- // src/integrations/linear/routes.ts
95
- import { registerApiRoute } from "@mastra/core/server";
96
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
97
- function loose(c) {
98
- return c;
99
- }
100
- async function resolveOrgTenant(c, auth) {
101
- await auth.ensureUser(c);
102
- const tenant = auth.tenant(c);
103
- if (!tenant) return { response: c.json({ error: "unauthorized" }, 401) };
104
- if (!tenant.orgId) {
105
- return {
106
- response: c.json(
107
- {
108
- error: "organization_required",
109
- message: "Linear intake requires an organization. Personal accounts cannot connect Linear."
110
- },
111
- 403
112
- )
113
- };
114
- }
115
- return { tenant: { orgId: tenant.orgId, userId: tenant.userId } };
116
- }
117
- function parseAfterCursor(raw) {
118
- if (raw === void 0 || raw === "") return void 0;
119
- if (raw.length > 512 || !/^[\w+/=.:-]+$/.test(raw)) return null;
120
- return raw;
121
- }
122
- function linearFetchError(c, err) {
123
- if (err instanceof LinearReauthRequiredError || err.status === 401) {
124
- return c.json({ error: "linear_reauth_required", message: new LinearReauthRequiredError().message }, 409);
125
- }
126
- return c.json({ error: "linear_fetch_failed", message: err instanceof Error ? err.message : String(err) }, 502);
127
- }
128
- function buildLinearRoutes(options) {
129
- const routes = [];
130
- const { linear, auth, stateSigner, intake } = options;
131
- const enabled = Boolean(linear) && auth.enabled();
132
- const diagnostics = () => ({
133
- linearAppConfigured: Boolean(linear),
134
- factoryAuthEnabled: auth.enabled(),
135
- appDbConfigured: true
136
- });
137
- routes.push(
138
- registerApiRoute("/web/linear/status", {
139
- method: "GET",
140
- requiresAuth: false,
141
- handler: async (c) => {
142
- if (!enabled || !linear || !stateSigner) {
143
- return c.json({
144
- enabled: false,
145
- connected: false,
146
- workspace: null,
147
- reason: "missing_config",
148
- diagnostics: diagnostics()
149
- });
150
- }
151
- await auth.ensureUser(loose(c));
152
- const tenant = auth.tenant(loose(c));
153
- if (!tenant) return c.json({ error: "unauthorized", reason: "auth_required" }, 401);
154
- if (!tenant.orgId) {
155
- return c.json({
156
- enabled: true,
157
- organizationRequired: true,
158
- connected: false,
159
- workspace: null,
160
- reason: "organization_required",
161
- diagnostics: diagnostics()
162
- });
163
- }
164
- const connection = await linear.loadConnection(tenant.orgId);
165
- return c.json({
166
- enabled: true,
167
- connected: Boolean(connection),
168
- workspace: connection ? { name: connection.workspaceName, urlKey: connection.workspaceUrlKey } : null,
169
- reason: connection ? "ready" : "not_connected",
170
- diagnostics: diagnostics()
171
- });
172
- }
173
- })
174
- );
175
- if (!enabled || !linear || !stateSigner || !intake) {
176
- return routes;
177
- }
178
- const redirectUri = options.redirectUri ?? `${(options.baseUrl ?? "").replace(/\/$/, "")}/auth/linear/callback`;
179
- routes.push(
180
- registerApiRoute("/auth/linear/connect", {
181
- method: "GET",
182
- requiresAuth: false,
183
- handler: async (c) => {
184
- const resolved = await resolveOrgTenant(loose(c), auth);
185
- if ("response" in resolved) return resolved.response;
186
- const state = stateSigner.sign(resolved.tenant.orgId, resolved.tenant.userId);
187
- return c.redirect(linear.buildAuthorizeUrl(state, redirectUri));
188
- }
189
- })
190
- );
191
- routes.push(
192
- registerApiRoute("/auth/linear/callback", {
193
- method: "GET",
194
- requiresAuth: false,
195
- handler: async (c) => {
196
- const resolved = await resolveOrgTenant(loose(c), auth);
197
- if ("response" in resolved) return resolved.response;
198
- const { orgId, userId } = resolved.tenant;
199
- const stateTenant = stateSigner.verify(c.req.query("state"));
200
- if (!stateTenant || stateTenant.userId !== userId || stateTenant.orgId !== orgId) {
201
- console.warn("[Linear] OAuth callback rejected: state/tenant mismatch.");
202
- return c.redirect("/?linear=error");
203
- }
204
- const code = c.req.query("code");
205
- if (!code) {
206
- return c.redirect("/?linear=error");
207
- }
208
- try {
209
- const tokens = await linear.exchangeOAuthCode(code, redirectUri);
210
- const workspace = await linear.fetchWorkspace(tokens.accessToken);
211
- await linear.upsertConnection({
212
- orgId,
213
- userId,
214
- accessToken: tokens.accessToken,
215
- refreshToken: tokens.refreshToken,
216
- expiresAt: tokens.expiresAt,
217
- scope: tokens.scope,
218
- workspaceName: workspace.name,
219
- workspaceUrlKey: workspace.urlKey
220
- });
221
- } catch (error) {
222
- console.warn(`[Linear] OAuth callback failed to persist connection for org ${orgId}.`, error);
223
- return c.redirect("/?linear=error");
224
- }
225
- return c.redirect("/?linear=connected");
226
- }
227
- })
228
- );
229
- routes.push(
230
- registerApiRoute("/web/linear/projects", {
231
- method: "GET",
232
- requiresAuth: false,
233
- handler: async (c) => {
234
- const resolved = await resolveOrgTenant(loose(c), auth);
235
- if ("response" in resolved) return resolved.response;
236
- const connection = await linear.loadConnection(resolved.tenant.orgId);
237
- if (!connection) {
238
- return c.json({ error: "linear_not_connected", message: "Connect Linear to list Linear projects." }, 409);
239
- }
240
- try {
241
- const accessToken = await linear.getFreshAccessToken(connection);
242
- const projects = await linear.listProjects(accessToken);
243
- return c.json({ projects });
244
- } catch (err) {
245
- return linearFetchError(loose(c), err);
246
- }
247
- }
248
- })
249
- );
250
- routes.push(
251
- registerApiRoute("/web/linear/issues", {
252
- method: "GET",
253
- requiresAuth: false,
254
- handler: async (c) => {
255
- const resolved = await resolveOrgTenant(loose(c), auth);
256
- if ("response" in resolved) return resolved.response;
257
- const after = parseAfterCursor(c.req.query("after"));
258
- if (after === null) return c.json({ error: "invalid_cursor" }, 400);
259
- const factoryProjectId = c.req.query("factoryProjectId");
260
- if (factoryProjectId && !UUID_RE.test(factoryProjectId)) {
261
- return c.json({ error: "invalid_factory_project_id" }, 400);
262
- }
263
- const connection = await linear.loadConnection(resolved.tenant.orgId);
264
- if (!connection) {
265
- return c.json({ error: "linear_not_connected", message: "Connect Linear to see intake issues." }, 409);
266
- }
267
- await intake.ensureReady();
268
- const config = await intake.getConfig({
269
- orgId: resolved.tenant.orgId,
270
- userId: resolved.tenant.userId,
271
- integrationIds: ["linear"]
272
- });
273
- const selection = config.linear;
274
- if (!selection.enabled) {
275
- return c.json({ error: "linear_intake_disabled", message: "Linear intake is turned off in Settings." }, 404);
276
- }
277
- const projectIds = selection.sourceIds ?? [];
278
- if (projectIds.length === 0) {
279
- return c.json({ issues: [], nextCursor: null });
280
- }
281
- try {
282
- const accessToken = await linear.getFreshAccessToken(connection);
283
- const { issues, nextCursor } = await linear.intake.listIssues({
284
- connection: { type: "oauth", accessToken },
285
- sourceIds: projectIds,
286
- cursor: after
287
- });
288
- const issuePayload = issues.map((issue) => ({
289
- id: issue.id,
290
- identifier: issue.identifier,
291
- title: issue.title,
292
- url: issue.url,
293
- state: issue.state ?? "",
294
- stateType: issue.stateType ?? "",
295
- priorityLabel: issue.priority ?? "",
296
- assignee: issue.assignee,
297
- team: issue.source,
298
- labels: issue.labels,
299
- createdAt: issue.createdAt,
300
- updatedAt: issue.updatedAt
301
- }));
302
- if (factoryProjectId && options.ingestFactoryIssues) {
303
- await options.ingestFactoryIssues({
304
- orgId: resolved.tenant.orgId,
305
- userId: resolved.tenant.userId,
306
- factoryProjectId,
307
- issues: issuePayload
308
- });
309
- }
310
- return c.json({ issues: issuePayload, nextCursor });
311
- } catch (err) {
312
- return linearFetchError(loose(c), err);
313
- }
314
- }
315
- })
316
- );
317
- return routes;
318
- }
319
-
320
- // src/rules/resolve.ts
321
- function resolveFactoryLinearRule(rules, event) {
322
- return rules.linear[event]?.onEvent;
323
- }
324
-
325
- // src/rules/types.ts
326
- var FACTORY_RULE_STAGES = ["intake", "triage", "planning", "execute", "review", "done", "canceled"];
327
- var FACTORY_RULE_BOARDS = ["work", "review"];
328
-
329
- // src/rules/validation.ts
330
- var MAX_FACTORY_RULE_CAUSAL_DEPTH = 8;
331
- var MAX_IDEMPOTENCY_KEY_LENGTH = 256;
332
- var MAX_REASON_LENGTH = 512;
333
- var MAX_TITLE_LENGTH = 512;
334
- var MAX_MESSAGE_LENGTH = 8192;
335
- var MAX_ARGUMENTS_LENGTH = 4096;
336
- var MAX_ROLE_LENGTH = 32;
337
- var MAX_SKILL_NAME_LENGTH = 128;
338
- var MAX_SOURCE_KEY_LENGTH = 256;
339
- var MAX_URL_LENGTH = 2048;
340
- var MAX_METADATA_JSON_LENGTH = 16384;
341
- var MAX_JSON_DEPTH = 8;
342
- var MAX_JSON_COLLECTION_SIZE = 100;
343
- var IDENTIFIER_RE = /^[a-z0-9][a-z0-9_-]*$/i;
344
- var SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
345
- var SENSITIVE_KEY_RE = /(?:authorization|cookie|credential|password|secret|token)/i;
346
- var WORK_ITEM_SOURCES = ["github-issue", "github-pr", "linear-issue", "manual"];
347
- var REJECTION_CODES = [
348
- "forbidden",
349
- "invalid_transition",
350
- "missing_binding",
351
- "stale",
352
- "timeout",
353
- "rule_error",
354
- "causal_depth_exceeded",
355
- "repeated_transition"
356
- ];
357
- var FactoryRuleValidationError = class extends Error {
358
- code = "invalid_factory_rule";
359
- constructor(message) {
360
- super(message);
361
- this.name = "FactoryRuleValidationError";
362
- }
363
- };
364
- function isPlainObject(value) {
365
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
366
- const prototype = Object.getPrototypeOf(value);
367
- return prototype === Object.prototype || prototype === null;
368
- }
369
- function assertExactKeys(value, keys, label) {
370
- const allowed = new Set(keys);
371
- if (Object.keys(value).some((key) => !allowed.has(key))) {
372
- throw new FactoryRuleValidationError(`${label} contains an unsupported field.`);
373
- }
374
- }
375
- function boundedString(value, label, max, pattern) {
376
- if (typeof value !== "string") throw new FactoryRuleValidationError(`${label} must be a string.`);
377
- const normalized = value.trim();
378
- if (normalized.length === 0 || normalized.length > max || pattern && !pattern.test(normalized)) {
379
- throw new FactoryRuleValidationError(`${label} is invalid.`);
380
- }
381
- return normalized;
382
- }
383
- function optionalBoundedString(value, label, max) {
384
- if (value === void 0) return void 0;
385
- return boundedString(value, label, max);
386
- }
387
- function enumValue(value, allowed, label) {
388
- if (typeof value !== "string" || !allowed.includes(value)) {
389
- throw new FactoryRuleValidationError(`${label} is invalid.`);
390
- }
391
- return value;
392
- }
393
- function normalizeFactoryRuleJsonValue(value, depth = 0, seen = /* @__PURE__ */ new Set()) {
394
- if (value === null || typeof value === "boolean" || typeof value === "string") return value;
395
- if (typeof value === "number") {
396
- if (!Number.isFinite(value)) throw new FactoryRuleValidationError("Rule metadata must contain finite numbers.");
397
- return value;
398
- }
399
- if (depth >= MAX_JSON_DEPTH || typeof value !== "object" && !Array.isArray(value)) {
400
- throw new FactoryRuleValidationError("Rule metadata is not bounded JSON.");
401
- }
402
- if (seen.has(value)) throw new FactoryRuleValidationError("Rule metadata must not contain cycles.");
403
- seen.add(value);
404
- try {
405
- if (Array.isArray(value)) {
406
- if (value.length > MAX_JSON_COLLECTION_SIZE) {
407
- throw new FactoryRuleValidationError("Rule metadata contains too many entries.");
408
- }
409
- return value.map((entry) => normalizeFactoryRuleJsonValue(entry, depth + 1, seen));
410
- }
411
- if (!isPlainObject(value)) throw new FactoryRuleValidationError("Rule metadata must use plain objects.");
412
- const entries = Object.entries(value);
413
- if (entries.length > MAX_JSON_COLLECTION_SIZE) {
414
- throw new FactoryRuleValidationError("Rule metadata contains too many fields.");
415
- }
416
- const sanitized = {};
417
- for (const [key, entry] of entries) {
418
- const normalizedKey = boundedString(key, "Rule metadata key", 128, IDENTIFIER_RE);
419
- sanitized[normalizedKey] = SENSITIVE_KEY_RE.test(normalizedKey) ? "[REDACTED]" : normalizeFactoryRuleJsonValue(entry, depth + 1, seen);
420
- }
421
- return sanitized;
422
- } finally {
423
- seen.delete(value);
424
- }
425
- }
426
- function sanitizeMetadata(value) {
427
- if (value === void 0) return void 0;
428
- const sanitized = normalizeFactoryRuleJsonValue(value);
429
- if (!isPlainObject(sanitized)) throw new FactoryRuleValidationError("Rule metadata must be an object.");
430
- if (JSON.stringify(sanitized).length > MAX_METADATA_JSON_LENGTH) {
431
- throw new FactoryRuleValidationError("Rule metadata is too large.");
432
- }
433
- return sanitized;
434
- }
435
- function commonCommitFields(value) {
436
- return {
437
- idempotencyKey: boundedString(value.idempotencyKey, "Factory decision idempotencyKey", MAX_IDEMPOTENCY_KEY_LENGTH)
438
- };
439
- }
440
- function validateFactoryRuleDecision(value, causalDepth = 0) {
441
- if (causalDepth > MAX_FACTORY_RULE_CAUSAL_DEPTH) {
442
- throw new FactoryRuleValidationError("Factory rule causal depth exceeded.");
443
- }
444
- if (!isPlainObject(value)) throw new FactoryRuleValidationError("Factory rule decision must be an object.");
445
- const type = value.type;
446
- if (typeof type !== "string") throw new FactoryRuleValidationError("Factory rule decision type is required.");
447
- switch (type) {
448
- case "reject": {
449
- assertExactKeys(value, ["type", "code", "reason"], "Factory reject decision");
450
- return {
451
- type,
452
- code: enumValue(value.code, REJECTION_CODES, "Factory rejection code"),
453
- reason: boundedString(value.reason, "Factory rejection reason", MAX_REASON_LENGTH)
454
- };
455
- }
456
- case "transition": {
457
- assertExactKeys(value, ["type", "idempotencyKey", "board", "stage"], "Factory transition decision");
458
- return {
459
- type,
460
- ...commonCommitFields(value),
461
- board: enumValue(value.board, FACTORY_RULE_BOARDS, "Factory transition board"),
462
- stage: enumValue(value.stage, FACTORY_RULE_STAGES, "Factory transition stage")
463
- };
464
- }
465
- case "upsertLinkedWorkItem": {
466
- assertExactKeys(
467
- value,
468
- ["type", "idempotencyKey", "board", "source", "sourceKey", "title", "url", "stage", "metadata"],
469
- "Factory linked work item decision"
470
- );
471
- const url = value.url;
472
- if (url !== null && (typeof url !== "string" || url.length > MAX_URL_LENGTH || !/^https?:\/\//.test(url))) {
473
- throw new FactoryRuleValidationError("Factory linked work item URL is invalid.");
474
- }
475
- const metadata = sanitizeMetadata(value.metadata);
476
- return {
477
- type,
478
- ...commonCommitFields(value),
479
- board: enumValue(value.board, FACTORY_RULE_BOARDS, "Factory linked work item board"),
480
- source: enumValue(value.source, WORK_ITEM_SOURCES, "Factory linked work item source"),
481
- sourceKey: boundedString(value.sourceKey, "Factory linked work item sourceKey", MAX_SOURCE_KEY_LENGTH),
482
- title: boundedString(value.title, "Factory linked work item title", MAX_TITLE_LENGTH),
483
- url,
484
- stage: enumValue(value.stage, FACTORY_RULE_STAGES, "Factory linked work item stage"),
485
- ...metadata ? { metadata } : {}
486
- };
487
- }
488
- case "invokeSkill": {
489
- assertExactKeys(
490
- value,
491
- ["type", "idempotencyKey", "role", "skillName", "arguments", "precedingMessage"],
492
- "Factory invoke skill decision"
493
- );
494
- const args = optionalBoundedString(value.arguments, "Factory skill arguments", MAX_ARGUMENTS_LENGTH);
495
- const precedingMessage = optionalBoundedString(
496
- value.precedingMessage,
497
- "Factory skill preceding message",
498
- MAX_MESSAGE_LENGTH
499
- );
500
- return {
501
- type,
502
- ...commonCommitFields(value),
503
- role: boundedString(value.role, "Factory skill role", MAX_ROLE_LENGTH, IDENTIFIER_RE),
504
- skillName: boundedString(value.skillName, "Factory skill name", MAX_SKILL_NAME_LENGTH, SKILL_NAME_RE),
505
- ...args ? { arguments: args } : {},
506
- ...precedingMessage ? { precedingMessage } : {}
507
- };
508
- }
509
- case "sendMessage": {
510
- assertExactKeys(
511
- value,
512
- ["type", "idempotencyKey", "role", "message", "priority", "idleBehavior", "prepareBinding"],
513
- "Factory send message decision"
514
- );
515
- const priority = value.priority === void 0 ? void 0 : enumValue(value.priority, ["medium", "high", "urgent"], "Factory message priority");
516
- const idleBehavior = value.idleBehavior === void 0 ? void 0 : enumValue(value.idleBehavior, ["persist", "wake"], "Factory message idle behavior");
517
- if (value.prepareBinding !== void 0 && typeof value.prepareBinding !== "boolean") {
518
- throw new FactoryRuleValidationError("Factory message prepareBinding must be a boolean.");
519
- }
520
- return {
521
- type,
522
- ...commonCommitFields(value),
523
- role: boundedString(value.role, "Factory message role", MAX_ROLE_LENGTH, IDENTIFIER_RE),
524
- message: boundedString(value.message, "Factory message", MAX_MESSAGE_LENGTH),
525
- ...priority ? { priority } : {},
526
- ...idleBehavior ? { idleBehavior } : {},
527
- ...value.prepareBinding === true ? { prepareBinding: true } : {}
528
- };
529
- }
530
- case "notify": {
531
- assertExactKeys(value, ["type", "idempotencyKey", "title", "body", "level"], "Factory notify decision");
532
- const body = optionalBoundedString(value.body, "Factory notification body", MAX_MESSAGE_LENGTH);
533
- const level = value.level === void 0 ? void 0 : enumValue(value.level, ["info", "warning", "error"], "Factory notification level");
534
- return {
535
- type,
536
- ...commonCommitFields(value),
537
- title: boundedString(value.title, "Factory notification title", MAX_TITLE_LENGTH),
538
- ...body ? { body } : {},
539
- ...level ? { level } : {}
540
- };
541
- }
542
- default:
543
- throw new FactoryRuleValidationError("Factory rule decision type is unsupported.");
544
- }
545
- }
546
- function validateFactoryRuleDecisions(values, causalDepth = 0) {
547
- if (values.length > MAX_JSON_COLLECTION_SIZE) {
548
- throw new FactoryRuleValidationError("Factory rule produced too many decisions.");
549
- }
550
- const decisions = [];
551
- for (const value of values) {
552
- const decision = validateFactoryRuleDecision(value, causalDepth);
553
- if (decision.type === "reject") {
554
- throw new FactoryRuleValidationError("A rejection cannot be persisted with commit decisions.");
555
- }
556
- decisions.push(decision);
557
- }
558
- const keys = decisions.map((decision) => decision.idempotencyKey);
559
- if (new Set(keys).size !== keys.length) {
560
- throw new FactoryRuleValidationError("Factory decisions require unique idempotency keys.");
561
- }
562
- return decisions;
563
- }
564
-
565
- // src/integrations/linear/rules.ts
566
- var RULE_TIMEOUT_MS = 5e3;
567
- async function withRuleTimeout(promise) {
568
- let timeout;
569
- try {
570
- return await Promise.race([
571
- promise,
572
- new Promise((_, reject) => {
573
- timeout = setTimeout(() => reject(new Error("FACTORY_RULE_TIMEOUT")), RULE_TIMEOUT_MS);
574
- })
575
- ]);
576
- } finally {
577
- if (timeout) clearTimeout(timeout);
578
- }
579
- }
580
- var LinearRules = class {
581
- constructor(options) {
582
- this.options = options;
583
- }
584
- options;
585
- async ingest(input) {
586
- const project = await this.options.projects.get({ orgId: input.orgId, id: input.factoryProjectId });
587
- if (!project) return { status: "missing", ingested: 0 };
588
- const items = await this.options.storage.list({ orgId: input.orgId, factoryProjectId: input.factoryProjectId });
589
- const itemsBySourceKey = new Map(items.map((item) => [item.externalSource?.externalId, item]));
590
- const statuses = [];
591
- for (const issue of input.issues) {
592
- statuses.push(await this.#ingestIssue(input, issue, itemsBySourceKey.get(`linear:${issue.identifier}`)));
593
- }
594
- if (statuses.some((status) => status === "committed")) return { status: "committed", ingested: statuses.length };
595
- if (statuses.some((status) => status === "replayed")) return { status: "replayed", ingested: statuses.length };
596
- return { status: "missing", ingested: statuses.length };
597
- }
598
- async #ingestIssue(input, issue, relatedItem) {
599
- const ingressId = `linear:${issue.id}:${issue.updatedAt}`;
600
- const actor = { type: "human", id: input.userId };
601
- const context = {
602
- tenant: { orgId: input.orgId, projectId: input.factoryProjectId },
603
- actor,
604
- ingress: { type: "linear", id: ingressId },
605
- cause: "linear.issueObserved",
606
- causalChain: [],
607
- ruleSetVersion: this.options.rules.version,
608
- ...relatedItem ? {
609
- item: {
610
- id: relatedItem.id,
611
- source: "linear-issue",
612
- sourceKey: relatedItem.externalSource?.externalId ?? null,
613
- parentWorkItemId: relatedItem.parentWorkItemId,
614
- title: relatedItem.title,
615
- url: relatedItem.externalSource?.url ?? null,
616
- stages: relatedItem.stages
617
- },
618
- board: "work",
619
- itemRevision: relatedItem.revision
620
- } : {},
621
- event: "issueObserved",
622
- issue
623
- };
624
- const rule = resolveFactoryLinearRule(this.options.rules, context.event);
625
- let decision;
626
- let decisions = [];
627
- let outcome = { status: "accepted" };
628
- try {
629
- decision = rule ? await withRuleTimeout(Promise.resolve(rule(Object.freeze(context)))) : void 0;
630
- if (decision?.type === "reject") {
631
- outcome = { status: "rejected", code: decision.code, reason: decision.reason };
632
- } else if (decision) {
633
- decisions = validateFactoryRuleDecisions([decision]).map((entry) => ({ ...entry }));
634
- }
635
- } catch (error) {
636
- const timedOut = error instanceof Error && error.message === "FACTORY_RULE_TIMEOUT";
637
- outcome = {
638
- status: "rejected",
639
- code: timedOut ? "timeout" : "rule_error",
640
- reason: timedOut ? "Factory rule evaluation timed out." : error instanceof Error ? error.message.slice(0, 2e3) : "Factory Linear rule failed."
641
- };
642
- }
643
- const committed = await this.options.storage.commitRuleEvaluation({
644
- orgId: input.orgId,
645
- factoryProjectId: input.factoryProjectId,
646
- workItemId: relatedItem?.id ?? null,
647
- ingress: { identity: ingressId, triggerType: "linear.issueObserved" },
648
- ruleSetVersion: this.options.rules.version,
649
- expectedRevision: relatedItem?.revision ?? null,
650
- actor,
651
- outcome,
652
- decisions,
653
- causalChain: [],
654
- now: /* @__PURE__ */ new Date()
655
- });
656
- return committed.status;
657
- }
658
- };
659
- function attachLinearRules(context) {
660
- if (!context.rules) return void 0;
661
- const rules = new LinearRules({
662
- projects: context.storage.projects,
663
- storage: context.rules.workItems,
664
- rules: context.rules.config
665
- });
666
- return (input) => rules.ingest(input);
667
- }
668
-
669
- // src/integrations/linear/integration.ts
670
- var LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
671
- var LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
672
- var LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
673
- var LINEAR_ISSUES_PAGE_SIZE = 30;
674
- var ISSUE_COMMENTS_PAGE_SIZE = 50;
675
- var ISSUE_COMMENTS_MAX_PAGES = 20;
676
- var TOKEN_REFRESH_SKEW_MS = 6e4;
677
- var CONNECTION_TTL_MS = 6e4;
678
- var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1
+ import { buildLinearRoutes } from "./routes.js";
2
+ import { attachLinearRules } from "./rules.js";
3
+ import { buildLinearAgentTools } from "./agent-tools.js";
4
+ //#region src/integrations/linear/integration.ts
5
+ const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
6
+ const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
7
+ const LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
8
+ const LINEAR_ISSUES_PAGE_SIZE = 30;
9
+ const ISSUE_COMMENTS_PAGE_SIZE = 50;
10
+ /** Hard stop for comment pagination so a misbehaving cursor can't loop forever. */
11
+ const ISSUE_COMMENTS_MAX_PAGES = 20;
12
+ /** Refresh this many ms before the recorded expiry to absorb clock skew. */
13
+ const TOKEN_REFRESH_SKEW_MS = 6e4;
14
+ /** Re-check an org's Linear connection (and its scopes) at most this often. */
15
+ const CONNECTION_TTL_MS = 6e4;
16
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ /** Thrown when the org's Linear authorization can no longer be renewed. */
679
18
  var LinearReauthRequiredError = class extends Error {
680
- constructor() {
681
- super("Linear authorization expired. Reconnect Linear to keep syncing intake issues.");
682
- }
19
+ constructor() {
20
+ super("Linear authorization expired. Reconnect Linear to keep syncing intake issues.");
21
+ }
683
22
  };
23
+ /** POST a GraphQL query to Linear with the given OAuth access token. */
684
24
  async function linearGraphql(accessToken, query, variables) {
685
- const res = await fetch(LINEAR_GRAPHQL_URL, {
686
- method: "POST",
687
- signal: AbortSignal.timeout(15e3),
688
- headers: {
689
- "content-type": "application/json",
690
- authorization: `Bearer ${accessToken}`
691
- },
692
- body: JSON.stringify({ query, variables })
693
- });
694
- if (!res.ok) {
695
- let detail = null;
696
- try {
697
- const errBody = await res.json();
698
- detail = errBody.errors?.[0]?.message ?? null;
699
- } catch {
700
- }
701
- const err = new Error(`Linear API request failed (${res.status})${detail ? `: ${detail}` : ""}`);
702
- err.status = res.status;
703
- throw err;
704
- }
705
- const body = await res.json();
706
- if (body.errors?.length) {
707
- throw new Error(`Linear API error: ${body.errors[0]?.message ?? "unknown error"}`);
708
- }
709
- if (!body.data) {
710
- throw new Error("Linear API returned no data.");
711
- }
712
- return body.data;
25
+ const res = await fetch(LINEAR_GRAPHQL_URL, {
26
+ method: "POST",
27
+ signal: AbortSignal.timeout(15e3),
28
+ headers: {
29
+ "content-type": "application/json",
30
+ authorization: `Bearer ${accessToken}`
31
+ },
32
+ body: JSON.stringify({
33
+ query,
34
+ variables
35
+ })
36
+ });
37
+ if (!res.ok) {
38
+ let detail = null;
39
+ try {
40
+ detail = (await res.json()).errors?.[0]?.message ?? null;
41
+ } catch {}
42
+ const err = /* @__PURE__ */ new Error(`Linear API request failed (${res.status})${detail ? `: ${detail}` : ""}`);
43
+ err.status = res.status;
44
+ throw err;
45
+ }
46
+ const body = await res.json();
47
+ if (body.errors?.length) throw new Error(`Linear API error: ${body.errors[0]?.message ?? "unknown error"}`);
48
+ if (!body.data) throw new Error("Linear API returned no data.");
49
+ return body.data;
713
50
  }
714
51
  var LinearIntegration = class {
715
- /** Stable integration identifier (see `../base.ts`). */
716
- id = "linear";
717
- /** Bound once by the factory via `initialize()` before any surface is used. */
718
- #storage;
719
- #projects;
720
- #auth;
721
- /** Bind Linear's slice of the generic integration storage, the projects domain, and the host auth seam. */
722
- initialize({
723
- storage,
724
- projects,
725
- auth
726
- }) {
727
- this.#storage = storage;
728
- this.#projects = projects;
729
- this.#auth = auth;
730
- }
731
- get storage() {
732
- if (!this.#storage) {
733
- throw new Error("LinearIntegration is not initialized \u2014 the factory binds storage during prepare().");
734
- }
735
- return this.#storage;
736
- }
737
- /** Factory projects domain maps a session's resourceId to its owning org. */
738
- get projects() {
739
- if (!this.#projects) {
740
- throw new Error("LinearIntegration is not initialized \u2014 the factory binds storage during prepare().");
741
- }
742
- return this.#projects;
743
- }
744
- /**
745
- * Whether the host runs with web auth enabled. Linear connections are
746
- * org-owned, so every Linear surface is inert without a tenant auth seam.
747
- */
748
- get authEnabled() {
749
- return this.#auth?.enabled() ?? false;
750
- }
751
- // ── Connection + OAuth token lifecycle ───────────────────────────────────
752
- /** Load the org's Linear connection, or `null` when not connected. */
753
- async loadConnection(orgId) {
754
- const connection = await this.storage.connections.get(orgId);
755
- if (!connection) return null;
756
- const { data } = connection;
757
- return {
758
- id: connection.id,
759
- orgId: connection.orgId,
760
- userId: connection.userId,
761
- accessToken: data.accessToken,
762
- scope: data.scope ?? null,
763
- refreshToken: data.refreshToken ?? null,
764
- expiresAt: data.expiresAtMs === null || data.expiresAtMs === void 0 ? null : new Date(data.expiresAtMs),
765
- workspaceName: data.workspaceName ?? null,
766
- workspaceUrlKey: data.workspaceUrlKey ?? null,
767
- createdAt: connection.createdAt,
768
- updatedAt: connection.updatedAt
769
- };
770
- }
771
- /** Insert or replace the org's connection (one per org). */
772
- async upsertConnection(input) {
773
- await this.storage.connections.upsert(input.orgId, {
774
- userId: input.userId,
775
- data: {
776
- accessToken: input.accessToken,
777
- refreshToken: input.refreshToken,
778
- expiresAtMs: input.expiresAt?.getTime() ?? null,
779
- scope: input.scope,
780
- workspaceName: input.workspaceName,
781
- workspaceUrlKey: input.workspaceUrlKey
782
- }
783
- });
784
- this.invalidateConnectionCache(input.orgId);
785
- }
786
- /** Persist a rotated token set on the org's existing connection row. */
787
- async #updateTokens(orgId, tokens) {
788
- await this.storage.connections.update(orgId, (data) => ({
789
- ...data,
790
- accessToken: tokens.accessToken,
791
- refreshToken: tokens.refreshToken,
792
- expiresAtMs: tokens.expiresAt?.getTime() ?? null,
793
- // Refresh responses may omit scope; keep the recorded grant.
794
- scope: tokens.scope ?? data.scope
795
- }));
796
- }
797
- /**
798
- * Whether the connection's token can post issue comments. Legacy rows
799
- * without a recorded scope were minted with `read` only, so they count as
800
- * read-only until the org reconnects Linear.
801
- */
802
- canPostComments(connection) {
803
- const scopes = (connection.scope ?? "").split(/[\s,]+/).filter(Boolean);
804
- return scopes.some((scope) => scope === "comments:create" || scope === "write" || scope === "admin");
805
- }
806
- /**
807
- * In-flight refreshes keyed by org. Linear rotates refresh tokens, so two
808
- * concurrent refreshes with the same token would invalidate each other
809
- * single-flight ensures one exchange per org and shares the result.
810
- */
811
- #inflightRefreshes = /* @__PURE__ */ new Map();
812
- /**
813
- * Return a usable access token for the connection, proactively refreshing
814
- * it when the recorded expiry is past (or imminent). Throws
815
- * `LinearReauthRequiredError` when the token is expired and cannot be
816
- * refreshed the org has to go through the OAuth flow again.
817
- */
818
- async getFreshAccessToken(connection) {
819
- const expired = connection.expiresAt !== null && connection.expiresAt.getTime() - TOKEN_REFRESH_SKEW_MS <= Date.now();
820
- if (!expired) return connection.accessToken;
821
- if (!connection.refreshToken) {
822
- throw new LinearReauthRequiredError();
823
- }
824
- const existing = this.#inflightRefreshes.get(connection.orgId);
825
- if (existing) return existing;
826
- const latest = await this.loadConnection(connection.orgId);
827
- if (!latest) throw new LinearReauthRequiredError();
828
- const concurrent = this.#inflightRefreshes.get(connection.orgId);
829
- if (concurrent) return concurrent;
830
- const latestExpired = latest.expiresAt !== null && latest.expiresAt.getTime() - TOKEN_REFRESH_SKEW_MS <= Date.now();
831
- if (!latestExpired) return latest.accessToken;
832
- if (!latest.refreshToken) throw new LinearReauthRequiredError();
833
- const refreshToken = latest.refreshToken;
834
- const refresh = (async () => {
835
- try {
836
- const tokens = await this.refreshAccessToken(refreshToken);
837
- await this.#updateTokens(connection.orgId, tokens);
838
- return tokens.accessToken;
839
- } catch (err) {
840
- const status = err.status;
841
- if (status === 400 || status === 401) throw new LinearReauthRequiredError();
842
- throw err;
843
- } finally {
844
- this.#inflightRefreshes.delete(connection.orgId);
845
- }
846
- })();
847
- this.#inflightRefreshes.set(connection.orgId, refresh);
848
- return refresh;
849
- }
850
- // ── Connection checks for agent tools ────────────────────────────────────
851
- /** Re-check an org's Linear connection (and its scopes) at most this often. */
852
- #connectionChecks = /* @__PURE__ */ new Map();
853
- /**
854
- * Whether the org has an active connection and whether its granted scope
855
- * allows posting comments. Cached per org with a short TTL — tool-set
856
- * resolution runs on every request.
857
- */
858
- async checkConnection(orgId) {
859
- const cached = this.#connectionChecks.get(orgId);
860
- if (cached && Date.now() - cached.checkedAt < CONNECTION_TTL_MS) return cached;
861
- const connection = await this.loadConnection(orgId);
862
- const check = {
863
- connected: connection !== null,
864
- canComment: connection !== null && this.canPostComments(connection),
865
- checkedAt: Date.now()
866
- };
867
- this.#connectionChecks.set(orgId, check);
868
- return check;
869
- }
870
- /**
871
- * Drop the cached connection check for an org. Called after a connection is
872
- * persisted so the tools show up on the very next run instead of after the
873
- * TTL lapses.
874
- */
875
- invalidateConnectionCache(orgId) {
876
- this.#connectionChecks.delete(orgId);
877
- }
878
- /**
879
- * A project's org never changes, so the resourceId → orgId mapping is
880
- * cached forever. `null` marks resource ids that aren't factory projects
881
- * (e.g. local default resources) so we don't re-query them on every
882
- * tool-set resolution.
883
- */
884
- #orgIdByResourceId = /* @__PURE__ */ new Map();
885
- /** Map a session's resourceId to its owning org, or `null` when it isn't a project. */
886
- async resolveOrgId(resourceId) {
887
- const cached = this.#orgIdByResourceId.get(resourceId);
888
- if (cached !== void 0) return cached;
889
- if (!UUID_PATTERN.test(resourceId)) {
890
- this.#orgIdByResourceId.set(resourceId, null);
891
- return null;
892
- }
893
- let orgId;
894
- try {
895
- await this.projects.ensureReady();
896
- const project = await this.projects.getById({ id: resourceId });
897
- orgId = project?.orgId ?? null;
898
- } catch {
899
- return null;
900
- }
901
- this.#orgIdByResourceId.set(resourceId, orgId);
902
- return orgId;
903
- }
904
- /** Test hook: clear the org/connection caches between specs. */
905
- clearCaches() {
906
- this.#orgIdByResourceId.clear();
907
- this.#connectionChecks.clear();
908
- }
909
- intake = {
910
- resolveIntakeDispatch: (input) => this.#resolveIntakeDispatch(input),
911
- listSources: async ({ orgId }) => {
912
- const connection = await this.loadConnection(orgId);
913
- if (!connection) return [];
914
- const accessToken = await this.getFreshAccessToken(connection);
915
- const projects = await this.listProjects(accessToken);
916
- return projects.map((project) => ({
917
- id: project.id,
918
- name: project.name,
919
- type: "project"
920
- }));
921
- },
922
- listItems: async ({ orgId, sourceIds, cursor }) => {
923
- if (sourceIds.length === 0) return { items: [], nextCursor: null };
924
- const connection = await this.loadConnection(orgId);
925
- if (!connection) return { items: [], nextCursor: null };
926
- const accessToken = await this.getFreshAccessToken(connection);
927
- const page = await this.listActiveIssues(accessToken, cursor, sourceIds);
928
- return {
929
- items: page.issues.map((issue) => ({
930
- source: { type: "issue", externalId: issue.id, url: issue.url },
931
- sourceId: issue.projectId,
932
- title: `${issue.identifier}: ${issue.title}`,
933
- status: issue.state,
934
- labels: issue.labels,
935
- assignee: issue.assignee,
936
- createdAt: issue.createdAt,
937
- updatedAt: issue.updatedAt,
938
- metadata: {
939
- identifier: issue.identifier,
940
- stateType: issue.stateType,
941
- priority: issue.priorityLabel,
942
- team: issue.team
943
- }
944
- })),
945
- nextCursor: page.nextCursor
946
- };
947
- },
948
- listIssues: (input) => this.#listIntakeIssues(input),
949
- getIssue: (input) => this.#getIntakeIssue(input),
950
- createComment: (input) => this.#createIntakeComment(input),
951
- updateIssue: (input) => this.#updateIntakeIssue(input)
952
- };
953
- /**
954
- * Background-dispatch context: the org's OAuth connection with a fresh
955
- * token. Linear work items store the issue UUID directly as `externalId`.
956
- */
957
- async #resolveIntakeDispatch({
958
- orgId,
959
- externalSource
960
- }) {
961
- if (externalSource.type !== "issue") return null;
962
- const connection = await this.loadConnection(orgId);
963
- if (!connection) return null;
964
- const accessToken = await this.getFreshAccessToken(connection);
965
- return { connection: { type: "oauth", accessToken }, issueId: externalSource.externalId };
966
- }
967
- /**
968
- * The OAuth connect/callback flow round-trips a signed `state` through
969
- * Linear, so a multi-replica deploy needs a deployment-stable state secret.
970
- */
971
- requiresStableStateSigner = true;
972
- #clientId;
973
- #clientSecret;
974
- constructor(config) {
975
- const missing = ["clientId", "clientSecret"].filter((key) => !config[key]);
976
- if (missing.length > 0) {
977
- throw new Error(`LinearIntegration is missing required config: ${missing.join(", ")}.`);
978
- }
979
- this.#clientId = config.clientId;
980
- this.#clientSecret = config.clientSecret;
981
- }
982
- // ── OAuth ────────────────────────────────────────────────────────────────
983
- /**
984
- * Build the OAuth authorize URL. `prompt=consent` forces the workspace
985
- * picker even for an already-authorized user, so "reconnect" can switch
986
- * workspaces.
987
- */
988
- buildAuthorizeUrl(state, redirectUri) {
989
- const url = new URL(LINEAR_AUTHORIZE_URL);
990
- url.searchParams.set("client_id", this.#clientId);
991
- url.searchParams.set("redirect_uri", redirectUri);
992
- url.searchParams.set("response_type", "code");
993
- url.searchParams.set("scope", "read,comments:create");
994
- url.searchParams.set("state", state);
995
- url.searchParams.set("prompt", "consent");
996
- return url.toString();
997
- }
998
- /** Exchange an OAuth `code` for a workspace-scoped token set. */
999
- async exchangeOAuthCode(code, redirectUri) {
1000
- return this.#requestTokens({ grant_type: "authorization_code", code, redirect_uri: redirectUri }, "token exchange");
1001
- }
1002
- /**
1003
- * Exchange a refresh token for a new token set. Linear rotates refresh
1004
- * tokens, so the returned set replaces the stored one entirely. A 400/401
1005
- * here means the refresh token is invalid/revoked and the org must
1006
- * re-authorize.
1007
- */
1008
- async refreshAccessToken(refreshToken) {
1009
- return this.#requestTokens({ grant_type: "refresh_token", refresh_token: refreshToken }, "token refresh");
1010
- }
1011
- /** POST to Linear's token endpoint and normalize the response. */
1012
- async #requestTokens(params, label) {
1013
- const res = await fetch(LINEAR_TOKEN_URL, {
1014
- method: "POST",
1015
- signal: AbortSignal.timeout(1e4),
1016
- headers: { "content-type": "application/x-www-form-urlencoded" },
1017
- body: new URLSearchParams({
1018
- ...params,
1019
- client_id: this.#clientId,
1020
- client_secret: this.#clientSecret
1021
- })
1022
- });
1023
- if (!res.ok) {
1024
- const err = new Error(`Linear ${label} failed (${res.status})`);
1025
- err.status = res.status;
1026
- throw err;
1027
- }
1028
- const body = await res.json();
1029
- if (!body.access_token) {
1030
- throw new Error(`Linear ${label} returned no access token.`);
1031
- }
1032
- return {
1033
- accessToken: body.access_token,
1034
- refreshToken: body.refresh_token ?? null,
1035
- expiresAt: typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1e3) : null,
1036
- scope: body.scope ?? null
1037
- };
1038
- }
1039
- // ── GraphQL reads/writes ─────────────────────────────────────────────────
1040
- /** Fetch the workspace (organization) the access token is scoped to. */
1041
- async fetchWorkspace(accessToken) {
1042
- const data = await linearGraphql(
1043
- accessToken,
1044
- `query { organization { name urlKey } }`
1045
- );
1046
- return { name: data.organization.name, urlKey: data.organization.urlKey };
1047
- }
1048
- async #listIntakeIssues(input) {
1049
- const accessToken = getLinearAccessToken(input.connection);
1050
- const result = await this.listActiveIssues(accessToken, input.cursor, input.sourceIds, input.labels);
1051
- return {
1052
- issues: result.issues.map((issue) => linearIssueToIntakeIssue(issue)),
1053
- nextCursor: result.nextCursor
1054
- };
1055
- }
1056
- async #getIntakeIssue(input) {
1057
- const accessToken = getLinearAccessToken(input.connection);
1058
- const issue = await this.fetchIssueDetail(accessToken, input.issueId);
1059
- if (!issue) return null;
1060
- return {
1061
- ...linearIssueToIntakeIssue(issue),
1062
- description: issue.description,
1063
- commentCount: issue.comments.length,
1064
- comments: issue.comments
1065
- };
1066
- }
1067
- async #createIntakeComment(input) {
1068
- const accessToken = getLinearAccessToken(input.connection);
1069
- return this.createIssueComment(accessToken, input.issueId, input.body);
1070
- }
1071
- async #updateIntakeIssue(input) {
1072
- const accessToken = getLinearAccessToken(input.connection);
1073
- const issue = await this.fetchIssueDetail(accessToken, input.issueId);
1074
- if (!issue) return null;
1075
- const teamKey = issue.team;
1076
- if (!teamKey) return null;
1077
- const states = await this.#listTeamWorkflowStates(accessToken, teamKey);
1078
- let targetState = null;
1079
- if (input.state.kind === "byType") {
1080
- const wantedType = input.state.stateType;
1081
- targetState = states.find((state) => state.type === wantedType) ?? null;
1082
- } else {
1083
- const wanted = input.state.name.toLowerCase();
1084
- targetState = states.find((state) => state.name.toLowerCase() === wanted) ?? null;
1085
- }
1086
- if (!targetState) return null;
1087
- if (targetState.name === issue.state) {
1088
- return linearIssueToIntakeIssue(issue);
1089
- }
1090
- const data = await linearGraphql(
1091
- accessToken,
1092
- `mutation UpdateIssueState($id: String!, $stateId: String!) {
52
+ /** Stable integration identifier (see `../base.ts`). */
53
+ id = "linear";
54
+ /** Bound once by the factory via `initialize()` before any surface is used. */
55
+ #storage;
56
+ #projects;
57
+ #auth;
58
+ /** Bind Linear's slice of the generic integration storage, the projects domain, and the host auth seam. */
59
+ initialize({ storage, projects, auth }) {
60
+ this.#storage = storage;
61
+ this.#projects = projects;
62
+ this.#auth = auth;
63
+ }
64
+ get storage() {
65
+ if (!this.#storage) throw new Error("LinearIntegration is not initialized — the factory binds storage during prepare().");
66
+ return this.#storage;
67
+ }
68
+ /** Factory projects domain — maps a session's resourceId to its owning org. */
69
+ get projects() {
70
+ if (!this.#projects) throw new Error("LinearIntegration is not initialized the factory binds storage during prepare().");
71
+ return this.#projects;
72
+ }
73
+ /**
74
+ * Whether the host runs with web auth enabled. Linear connections are
75
+ * org-owned, so every Linear surface is inert without a tenant auth seam.
76
+ */
77
+ get authEnabled() {
78
+ return this.#auth?.enabled() ?? false;
79
+ }
80
+ /** Load the org's Linear connection, or `null` when not connected. */
81
+ async loadConnection(orgId) {
82
+ const connection = await this.storage.connections.get(orgId);
83
+ if (!connection) return null;
84
+ const { data } = connection;
85
+ return {
86
+ id: connection.id,
87
+ orgId: connection.orgId,
88
+ userId: connection.userId,
89
+ accessToken: data.accessToken,
90
+ scope: data.scope ?? null,
91
+ refreshToken: data.refreshToken ?? null,
92
+ expiresAt: data.expiresAtMs === null || data.expiresAtMs === void 0 ? null : new Date(data.expiresAtMs),
93
+ workspaceName: data.workspaceName ?? null,
94
+ workspaceUrlKey: data.workspaceUrlKey ?? null,
95
+ createdAt: connection.createdAt,
96
+ updatedAt: connection.updatedAt
97
+ };
98
+ }
99
+ /** Insert or replace the org's connection (one per org). */
100
+ async upsertConnection(input) {
101
+ await this.storage.connections.upsert(input.orgId, {
102
+ userId: input.userId,
103
+ data: {
104
+ accessToken: input.accessToken,
105
+ refreshToken: input.refreshToken,
106
+ expiresAtMs: input.expiresAt?.getTime() ?? null,
107
+ scope: input.scope,
108
+ workspaceName: input.workspaceName,
109
+ workspaceUrlKey: input.workspaceUrlKey
110
+ }
111
+ });
112
+ this.invalidateConnectionCache(input.orgId);
113
+ }
114
+ /** Persist a rotated token set on the org's existing connection row. */
115
+ async #updateTokens(orgId, tokens) {
116
+ await this.storage.connections.update(orgId, (data) => ({
117
+ ...data,
118
+ accessToken: tokens.accessToken,
119
+ refreshToken: tokens.refreshToken,
120
+ expiresAtMs: tokens.expiresAt?.getTime() ?? null,
121
+ scope: tokens.scope ?? data.scope
122
+ }));
123
+ }
124
+ /**
125
+ * Whether the connection's token can post issue comments. Legacy rows
126
+ * without a recorded scope were minted with `read` only, so they count as
127
+ * read-only until the org reconnects Linear.
128
+ */
129
+ canPostComments(connection) {
130
+ return (connection.scope ?? "").split(/[\s,]+/).filter(Boolean).some((scope) => scope === "comments:create" || scope === "write" || scope === "admin");
131
+ }
132
+ /**
133
+ * In-flight refreshes keyed by org. Linear rotates refresh tokens, so two
134
+ * concurrent refreshes with the same token would invalidate each other —
135
+ * single-flight ensures one exchange per org and shares the result.
136
+ */
137
+ #inflightRefreshes = /* @__PURE__ */ new Map();
138
+ /**
139
+ * Return a usable access token for the connection, proactively refreshing
140
+ * it when the recorded expiry is past (or imminent). Throws
141
+ * `LinearReauthRequiredError` when the token is expired and cannot be
142
+ * refreshed — the org has to go through the OAuth flow again.
143
+ */
144
+ async getFreshAccessToken(connection) {
145
+ if (!(connection.expiresAt !== null && connection.expiresAt.getTime() - TOKEN_REFRESH_SKEW_MS <= Date.now())) return connection.accessToken;
146
+ if (!connection.refreshToken) throw new LinearReauthRequiredError();
147
+ const existing = this.#inflightRefreshes.get(connection.orgId);
148
+ if (existing) return existing;
149
+ const latest = await this.loadConnection(connection.orgId);
150
+ if (!latest) throw new LinearReauthRequiredError();
151
+ const concurrent = this.#inflightRefreshes.get(connection.orgId);
152
+ if (concurrent) return concurrent;
153
+ if (!(latest.expiresAt !== null && latest.expiresAt.getTime() - TOKEN_REFRESH_SKEW_MS <= Date.now())) return latest.accessToken;
154
+ if (!latest.refreshToken) throw new LinearReauthRequiredError();
155
+ const refreshToken = latest.refreshToken;
156
+ const refresh = (async () => {
157
+ try {
158
+ const tokens = await this.refreshAccessToken(refreshToken);
159
+ await this.#updateTokens(connection.orgId, tokens);
160
+ return tokens.accessToken;
161
+ } catch (err) {
162
+ const status = err.status;
163
+ if (status === 400 || status === 401) throw new LinearReauthRequiredError();
164
+ throw err;
165
+ } finally {
166
+ this.#inflightRefreshes.delete(connection.orgId);
167
+ }
168
+ })();
169
+ this.#inflightRefreshes.set(connection.orgId, refresh);
170
+ return refresh;
171
+ }
172
+ /** Re-check an org's Linear connection (and its scopes) at most this often. */
173
+ #connectionChecks = /* @__PURE__ */ new Map();
174
+ /**
175
+ * Whether the org has an active connection and whether its granted scope
176
+ * allows posting comments. Cached per org with a short TTL — tool-set
177
+ * resolution runs on every request.
178
+ */
179
+ async checkConnection(orgId) {
180
+ const cached = this.#connectionChecks.get(orgId);
181
+ if (cached && Date.now() - cached.checkedAt < CONNECTION_TTL_MS) return cached;
182
+ const connection = await this.loadConnection(orgId);
183
+ const check = {
184
+ connected: connection !== null,
185
+ canComment: connection !== null && this.canPostComments(connection),
186
+ checkedAt: Date.now()
187
+ };
188
+ this.#connectionChecks.set(orgId, check);
189
+ return check;
190
+ }
191
+ /**
192
+ * Drop the cached connection check for an org. Called after a connection is
193
+ * persisted so the tools show up on the very next run instead of after the
194
+ * TTL lapses.
195
+ */
196
+ invalidateConnectionCache(orgId) {
197
+ this.#connectionChecks.delete(orgId);
198
+ }
199
+ /**
200
+ * A project's org never changes, so the resourceId → orgId mapping is
201
+ * cached forever. `null` marks resource ids that aren't factory projects
202
+ * (e.g. local default resources) so we don't re-query them on every
203
+ * tool-set resolution.
204
+ */
205
+ #orgIdByResourceId = /* @__PURE__ */ new Map();
206
+ /** Map a session's resourceId to its owning org, or `null` when it isn't a project. */
207
+ async resolveOrgId(resourceId) {
208
+ const cached = this.#orgIdByResourceId.get(resourceId);
209
+ if (cached !== void 0) return cached;
210
+ if (!UUID_PATTERN.test(resourceId)) {
211
+ this.#orgIdByResourceId.set(resourceId, null);
212
+ return null;
213
+ }
214
+ let orgId;
215
+ try {
216
+ await this.projects.ensureReady();
217
+ orgId = (await this.projects.getById({ id: resourceId }))?.orgId ?? null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ this.#orgIdByResourceId.set(resourceId, orgId);
222
+ return orgId;
223
+ }
224
+ /** Test hook: clear the org/connection caches between specs. */
225
+ clearCaches() {
226
+ this.#orgIdByResourceId.clear();
227
+ this.#connectionChecks.clear();
228
+ }
229
+ intake = {
230
+ resolveIntakeDispatch: (input) => this.#resolveIntakeDispatch(input),
231
+ listSources: async ({ orgId }) => {
232
+ const connection = await this.loadConnection(orgId);
233
+ if (!connection) return [];
234
+ const accessToken = await this.getFreshAccessToken(connection);
235
+ return (await this.listProjects(accessToken)).map((project) => ({
236
+ id: project.id,
237
+ name: project.name,
238
+ type: "project"
239
+ }));
240
+ },
241
+ listItems: async ({ orgId, sourceIds, cursor }) => {
242
+ if (sourceIds.length === 0) return {
243
+ items: [],
244
+ nextCursor: null
245
+ };
246
+ const connection = await this.loadConnection(orgId);
247
+ if (!connection) return {
248
+ items: [],
249
+ nextCursor: null
250
+ };
251
+ const accessToken = await this.getFreshAccessToken(connection);
252
+ const page = await this.listActiveIssues(accessToken, cursor, sourceIds);
253
+ return {
254
+ items: page.issues.map((issue) => ({
255
+ source: {
256
+ type: "issue",
257
+ externalId: issue.id,
258
+ url: issue.url
259
+ },
260
+ sourceId: issue.projectId,
261
+ title: `${issue.identifier}: ${issue.title}`,
262
+ status: issue.state,
263
+ labels: issue.labels,
264
+ assignee: issue.assignee,
265
+ createdAt: issue.createdAt,
266
+ updatedAt: issue.updatedAt,
267
+ metadata: {
268
+ identifier: issue.identifier,
269
+ stateType: issue.stateType,
270
+ priority: issue.priorityLabel,
271
+ team: issue.team
272
+ }
273
+ })),
274
+ nextCursor: page.nextCursor
275
+ };
276
+ },
277
+ listIssues: (input) => this.#listIntakeIssues(input),
278
+ getIssue: (input) => this.#getIntakeIssue(input),
279
+ createComment: (input) => this.#createIntakeComment(input),
280
+ updateIssue: (input) => this.#updateIntakeIssue(input)
281
+ };
282
+ /**
283
+ * Background-dispatch context: the org's OAuth connection with a fresh
284
+ * token. Linear work items store the issue UUID directly as `externalId`.
285
+ */
286
+ async #resolveIntakeDispatch({ orgId, externalSource }) {
287
+ if (externalSource.type !== "issue") return null;
288
+ const connection = await this.loadConnection(orgId);
289
+ if (!connection) return null;
290
+ return {
291
+ connection: {
292
+ type: "oauth",
293
+ accessToken: await this.getFreshAccessToken(connection)
294
+ },
295
+ issueId: externalSource.externalId
296
+ };
297
+ }
298
+ /**
299
+ * The OAuth connect/callback flow round-trips a signed `state` through
300
+ * Linear, so a multi-replica deploy needs a deployment-stable state secret.
301
+ */
302
+ requiresStableStateSigner = true;
303
+ #clientId;
304
+ #clientSecret;
305
+ constructor(config) {
306
+ const missing = ["clientId", "clientSecret"].filter((key) => !config[key]);
307
+ if (missing.length > 0) throw new Error(`LinearIntegration is missing required config: ${missing.join(", ")}.`);
308
+ this.#clientId = config.clientId;
309
+ this.#clientSecret = config.clientSecret;
310
+ }
311
+ /**
312
+ * Build the OAuth authorize URL. `prompt=consent` forces the workspace
313
+ * picker even for an already-authorized user, so "reconnect" can switch
314
+ * workspaces.
315
+ */
316
+ buildAuthorizeUrl(state, redirectUri) {
317
+ const url = new URL(LINEAR_AUTHORIZE_URL);
318
+ url.searchParams.set("client_id", this.#clientId);
319
+ url.searchParams.set("redirect_uri", redirectUri);
320
+ url.searchParams.set("response_type", "code");
321
+ url.searchParams.set("scope", "read,comments:create");
322
+ url.searchParams.set("state", state);
323
+ url.searchParams.set("prompt", "consent");
324
+ return url.toString();
325
+ }
326
+ /** Exchange an OAuth `code` for a workspace-scoped token set. */
327
+ async exchangeOAuthCode(code, redirectUri) {
328
+ return this.#requestTokens({
329
+ grant_type: "authorization_code",
330
+ code,
331
+ redirect_uri: redirectUri
332
+ }, "token exchange");
333
+ }
334
+ /**
335
+ * Exchange a refresh token for a new token set. Linear rotates refresh
336
+ * tokens, so the returned set replaces the stored one entirely. A 400/401
337
+ * here means the refresh token is invalid/revoked and the org must
338
+ * re-authorize.
339
+ */
340
+ async refreshAccessToken(refreshToken) {
341
+ return this.#requestTokens({
342
+ grant_type: "refresh_token",
343
+ refresh_token: refreshToken
344
+ }, "token refresh");
345
+ }
346
+ /** POST to Linear's token endpoint and normalize the response. */
347
+ async #requestTokens(params, label) {
348
+ const res = await fetch(LINEAR_TOKEN_URL, {
349
+ method: "POST",
350
+ signal: AbortSignal.timeout(1e4),
351
+ headers: { "content-type": "application/x-www-form-urlencoded" },
352
+ body: new URLSearchParams({
353
+ ...params,
354
+ client_id: this.#clientId,
355
+ client_secret: this.#clientSecret
356
+ })
357
+ });
358
+ if (!res.ok) {
359
+ const err = /* @__PURE__ */ new Error(`Linear ${label} failed (${res.status})`);
360
+ err.status = res.status;
361
+ throw err;
362
+ }
363
+ const body = await res.json();
364
+ if (!body.access_token) throw new Error(`Linear ${label} returned no access token.`);
365
+ return {
366
+ accessToken: body.access_token,
367
+ refreshToken: body.refresh_token ?? null,
368
+ expiresAt: typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1e3) : null,
369
+ scope: body.scope ?? null
370
+ };
371
+ }
372
+ /** Fetch the workspace (organization) the access token is scoped to. */
373
+ async fetchWorkspace(accessToken) {
374
+ const data = await linearGraphql(accessToken, `query { organization { name urlKey } }`);
375
+ return {
376
+ name: data.organization.name,
377
+ urlKey: data.organization.urlKey
378
+ };
379
+ }
380
+ async #listIntakeIssues(input) {
381
+ const accessToken = getLinearAccessToken(input.connection);
382
+ const result = await this.listActiveIssues(accessToken, input.cursor, input.sourceIds, input.labels);
383
+ return {
384
+ issues: result.issues.map((issue) => linearIssueToIntakeIssue(issue)),
385
+ nextCursor: result.nextCursor
386
+ };
387
+ }
388
+ async #getIntakeIssue(input) {
389
+ const accessToken = getLinearAccessToken(input.connection);
390
+ const issue = await this.fetchIssueDetail(accessToken, input.issueId);
391
+ if (!issue) return null;
392
+ return {
393
+ ...linearIssueToIntakeIssue(issue),
394
+ description: issue.description,
395
+ commentCount: issue.comments.length,
396
+ comments: issue.comments
397
+ };
398
+ }
399
+ async #createIntakeComment(input) {
400
+ const accessToken = getLinearAccessToken(input.connection);
401
+ return this.createIssueComment(accessToken, input.issueId, input.body);
402
+ }
403
+ async #updateIntakeIssue(input) {
404
+ const accessToken = getLinearAccessToken(input.connection);
405
+ const issue = await this.fetchIssueDetail(accessToken, input.issueId);
406
+ if (!issue) return null;
407
+ const teamKey = issue.team;
408
+ if (!teamKey) return null;
409
+ const states = await this.#listTeamWorkflowStates(accessToken, teamKey);
410
+ let targetState = null;
411
+ if (input.state.kind === "byType") {
412
+ const wantedType = input.state.stateType;
413
+ targetState = states.find((state) => state.type === wantedType) ?? null;
414
+ } else {
415
+ const wanted = input.state.name.toLowerCase();
416
+ targetState = states.find((state) => state.name.toLowerCase() === wanted) ?? null;
417
+ }
418
+ if (!targetState) return null;
419
+ if (targetState.name === issue.state) return linearIssueToIntakeIssue(issue);
420
+ if (!(await linearGraphql(accessToken, `mutation UpdateIssueState($id: String!, $stateId: String!) {
1093
421
  issueUpdate(id: $id, input: { stateId: $stateId }) { success }
1094
- }`,
1095
- { id: issue.id, stateId: targetState.id }
1096
- );
1097
- if (!data.issueUpdate.success) {
1098
- throw new Error("Linear did not accept the issue update.");
1099
- }
1100
- const fresh = await this.fetchIssueDetail(accessToken, issue.id);
1101
- if (!fresh) return null;
1102
- return linearIssueToIntakeIssue(fresh);
1103
- }
1104
- async #listTeamWorkflowStates(accessToken, teamKey) {
1105
- const data = await linearGraphql(
1106
- accessToken,
1107
- `query TeamStates($key: String!) {
422
+ }`, {
423
+ id: issue.id,
424
+ stateId: targetState.id
425
+ })).issueUpdate.success) throw new Error("Linear did not accept the issue update.");
426
+ const fresh = await this.fetchIssueDetail(accessToken, issue.id);
427
+ if (!fresh) return null;
428
+ return linearIssueToIntakeIssue(fresh);
429
+ }
430
+ async #listTeamWorkflowStates(accessToken, teamKey) {
431
+ return (await linearGraphql(accessToken, `query TeamStates($key: String!) {
1108
432
  team(id: $key) { states(first: 100) { nodes { id name type } } }
1109
- }`,
1110
- { key: teamKey }
1111
- );
1112
- return data.team?.states.nodes ?? [];
1113
- }
1114
- /** List the workspace's projects (for the Settings intake-source picker). */
1115
- async listProjects(accessToken) {
1116
- const data = await linearGraphql(
1117
- accessToken,
1118
- `query { projects(first: 100) { nodes { id name state teams(first: 10) { nodes { id key name } } } } }`
1119
- );
1120
- return data.projects.nodes.map((node) => ({
1121
- id: node.id,
1122
- name: node.name,
1123
- state: node.state,
1124
- teams: node.teams.nodes.map((team) => ({ id: team.id, key: team.key, name: team.name }))
1125
- }));
1126
- }
1127
- /**
1128
- * List one page of the workspace's active issues (triage/backlog/unstarted/
1129
- * started — completed and canceled are excluded), most recently updated
1130
- * first. When `projectIds` is provided, only issues from those projects are
1131
- * returned.
1132
- */
1133
- async listActiveIssues(accessToken, after, projectIds, labels) {
1134
- const normalizedLabels = [...new Set((labels ?? []).map((label) => label.trim()).filter(Boolean))];
1135
- const projectFilter = projectIds?.length ? ", project: { id: { in: $projectIds } }" : "";
1136
- const projectVar = projectIds?.length ? ", $projectIds: [ID!]" : "";
1137
- const labelFilter = normalizedLabels.length > 0 ? ", labels: { name: { in: $labels } }" : "";
1138
- const labelVar = normalizedLabels.length > 0 ? ", $labels: [String!]" : "";
1139
- const data = await linearGraphql(
1140
- accessToken,
1141
- `query Intake($first: Int!, $after: String${projectVar}${labelVar}) {
433
+ }`, { key: teamKey })).team?.states.nodes ?? [];
434
+ }
435
+ /** List the workspace's projects (for the Settings intake-source picker). */
436
+ async listProjects(accessToken) {
437
+ return (await linearGraphql(accessToken, `query { projects(first: 100) { nodes { id name state teams(first: 10) { nodes { id key name } } } } }`)).projects.nodes.map((node) => ({
438
+ id: node.id,
439
+ name: node.name,
440
+ state: node.state,
441
+ teams: node.teams.nodes.map((team) => ({
442
+ id: team.id,
443
+ key: team.key,
444
+ name: team.name
445
+ }))
446
+ }));
447
+ }
448
+ /**
449
+ * List one page of the workspace's active issues (triage/backlog/unstarted/
450
+ * started — completed and canceled are excluded), most recently updated
451
+ * first. When `projectIds` is provided, only issues from those projects are
452
+ * returned.
453
+ */
454
+ async listActiveIssues(accessToken, after, projectIds, labels) {
455
+ const normalizedLabels = [...new Set((labels ?? []).map((label) => label.trim()).filter(Boolean))];
456
+ const projectFilter = projectIds?.length ? ", project: { id: { in: $projectIds } }" : "";
457
+ const projectVar = projectIds?.length ? ", $projectIds: [ID!]" : "";
458
+ const labelFilter = normalizedLabels.length > 0 ? ", labels: { name: { in: $labels } }" : "";
459
+ const { nodes, pageInfo } = (await linearGraphql(accessToken, `query Intake($first: Int!, $after: String${projectVar}${normalizedLabels.length > 0 ? ", $labels: [String!]" : ""}) {
1142
460
  issues(
1143
461
  first: $first
1144
462
  after: $after
@@ -1161,70 +479,64 @@ var LinearIntegration = class {
1161
479
  }
1162
480
  pageInfo { hasNextPage endCursor }
1163
481
  }
1164
- }`,
1165
- {
1166
- first: LINEAR_ISSUES_PAGE_SIZE,
1167
- after: after ?? null,
1168
- ...projectIds?.length ? { projectIds } : {},
1169
- ...normalizedLabels.length > 0 ? { labels: normalizedLabels } : {}
1170
- }
1171
- );
1172
- const { nodes, pageInfo } = data.issues;
1173
- return {
1174
- issues: nodes.map((node) => ({
1175
- id: node.id,
1176
- projectId: node.project.id,
1177
- identifier: node.identifier,
1178
- title: node.title,
1179
- url: node.url,
1180
- state: node.state.name,
1181
- stateType: node.state.type,
1182
- priorityLabel: node.priorityLabel,
1183
- assignee: node.assignee?.name ?? null,
1184
- team: node.team?.key ?? null,
1185
- labels: node.labels.nodes.map((label) => label.name),
1186
- createdAt: node.createdAt,
1187
- updatedAt: node.updatedAt
1188
- })),
1189
- nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null
1190
- };
1191
- }
1192
- /** Follow `comments.pageInfo` until exhausted so long discussions aren't truncated. */
1193
- async #fetchRemainingIssueComments(accessToken, issueId, firstPage) {
1194
- const nodes = [...firstPage.nodes];
1195
- let { hasNextPage, endCursor } = firstPage.pageInfo;
1196
- for (let page = 1; hasNextPage && endCursor && page < ISSUE_COMMENTS_MAX_PAGES; page++) {
1197
- const data = await linearGraphql(
1198
- accessToken,
1199
- `query IssueComments($id: String!, $first: Int!, $after: String!) {
482
+ }`, {
483
+ first: LINEAR_ISSUES_PAGE_SIZE,
484
+ after: after ?? null,
485
+ ...projectIds?.length ? { projectIds } : {},
486
+ ...normalizedLabels.length > 0 ? { labels: normalizedLabels } : {}
487
+ })).issues;
488
+ return {
489
+ issues: nodes.map((node) => ({
490
+ id: node.id,
491
+ projectId: node.project.id,
492
+ identifier: node.identifier,
493
+ title: node.title,
494
+ url: node.url,
495
+ state: node.state.name,
496
+ stateType: node.state.type,
497
+ priorityLabel: node.priorityLabel,
498
+ assignee: node.assignee?.name ?? null,
499
+ team: node.team?.key ?? null,
500
+ labels: node.labels.nodes.map((label) => label.name),
501
+ createdAt: node.createdAt,
502
+ updatedAt: node.updatedAt
503
+ })),
504
+ nextCursor: pageInfo.hasNextPage ? pageInfo.endCursor : null
505
+ };
506
+ }
507
+ /** Follow `comments.pageInfo` until exhausted so long discussions aren't truncated. */
508
+ async #fetchRemainingIssueComments(accessToken, issueId, firstPage) {
509
+ const nodes = [...firstPage.nodes];
510
+ let { hasNextPage, endCursor } = firstPage.pageInfo;
511
+ for (let page = 1; hasNextPage && endCursor && page < ISSUE_COMMENTS_MAX_PAGES; page++) {
512
+ const comments = (await linearGraphql(accessToken, `query IssueComments($id: String!, $first: Int!, $after: String!) {
1200
513
  issue(id: $id) {
1201
514
  comments(first: $first, after: $after) {
1202
515
  nodes { body createdAt user { name } }
1203
516
  pageInfo { hasNextPage endCursor }
1204
517
  }
1205
518
  }
1206
- }`,
1207
- { id: issueId, first: ISSUE_COMMENTS_PAGE_SIZE, after: endCursor }
1208
- );
1209
- const comments = data.issue?.comments;
1210
- if (!comments) break;
1211
- nodes.push(...comments.nodes);
1212
- ({ hasNextPage, endCursor } = comments.pageInfo);
1213
- }
1214
- return nodes;
1215
- }
1216
- /**
1217
- * Fetch one issue with its description and comments. `idOrIdentifier`
1218
- * accepts both the Linear UUID and the human key (`ENG-123`). Returns
1219
- * `null` when the issue doesn't exist (Linear reports it as an "Entity not
1220
- * found" error).
1221
- */
1222
- async fetchIssueDetail(accessToken, idOrIdentifier) {
1223
- let data;
1224
- try {
1225
- data = await linearGraphql(
1226
- accessToken,
1227
- `query IssueDetail($id: String!, $commentsFirst: Int!) {
519
+ }`, {
520
+ id: issueId,
521
+ first: ISSUE_COMMENTS_PAGE_SIZE,
522
+ after: endCursor
523
+ })).issue?.comments;
524
+ if (!comments) break;
525
+ nodes.push(...comments.nodes);
526
+ ({hasNextPage, endCursor} = comments.pageInfo);
527
+ }
528
+ return nodes;
529
+ }
530
+ /**
531
+ * Fetch one issue with its description and comments. `idOrIdentifier`
532
+ * accepts both the Linear UUID and the human key (`ENG-123`). Returns
533
+ * `null` when the issue doesn't exist (Linear reports it as an "Entity not
534
+ * found" error).
535
+ */
536
+ async fetchIssueDetail(accessToken, idOrIdentifier) {
537
+ let data;
538
+ try {
539
+ data = await linearGraphql(accessToken, `query IssueDetail($id: String!, $commentsFirst: Int!) {
1228
540
  issue(id: $id) {
1229
541
  id
1230
542
  identifier
@@ -1244,127 +556,117 @@ var LinearIntegration = class {
1244
556
  pageInfo { hasNextPage endCursor }
1245
557
  }
1246
558
  }
1247
- }`,
1248
- { id: idOrIdentifier, commentsFirst: ISSUE_COMMENTS_PAGE_SIZE }
1249
- );
1250
- } catch (err) {
1251
- if (err instanceof Error && /entity not found/i.test(err.message)) return null;
1252
- throw err;
1253
- }
1254
- const issue = data.issue;
1255
- if (!issue) return null;
1256
- const allComments = await this.#fetchRemainingIssueComments(accessToken, issue.id, issue.comments);
1257
- const comments = allComments.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1258
- return {
1259
- id: issue.id,
1260
- projectId: issue.project.id,
1261
- identifier: issue.identifier,
1262
- title: issue.title,
1263
- description: issue.description?.trim() ? issue.description : null,
1264
- url: issue.url,
1265
- state: issue.state.name,
1266
- stateType: issue.state.type,
1267
- priorityLabel: issue.priorityLabel,
1268
- assignee: issue.assignee?.name ?? null,
1269
- team: issue.team?.key ?? null,
1270
- labels: issue.labels.nodes.map((label) => label.name),
1271
- createdAt: issue.createdAt,
1272
- updatedAt: issue.updatedAt,
1273
- comments: comments.map((comment) => ({
1274
- author: comment.user?.name ?? null,
1275
- body: comment.body,
1276
- createdAt: comment.createdAt
1277
- }))
1278
- };
1279
- }
1280
- /**
1281
- * Post a comment on an issue. `idOrIdentifier` accepts both the Linear UUID
1282
- * and the human key (`ENG-123`) — the identifier is resolved to a UUID
1283
- * first because `commentCreate` only accepts UUIDs. Returns `null` when the
1284
- * issue doesn't exist.
1285
- */
1286
- async createIssueComment(accessToken, idOrIdentifier, body) {
1287
- let issueId;
1288
- try {
1289
- const data2 = await linearGraphql(
1290
- accessToken,
1291
- `query IssueId($id: String!) { issue(id: $id) { id } }`,
1292
- { id: idOrIdentifier }
1293
- );
1294
- if (!data2.issue) return null;
1295
- issueId = data2.issue.id;
1296
- } catch (err) {
1297
- if (err instanceof Error && /entity not found/i.test(err.message)) return null;
1298
- throw err;
1299
- }
1300
- const data = await linearGraphql(
1301
- accessToken,
1302
- `mutation CommentCreate($input: CommentCreateInput!) {
559
+ }`, {
560
+ id: idOrIdentifier,
561
+ commentsFirst: ISSUE_COMMENTS_PAGE_SIZE
562
+ });
563
+ } catch (err) {
564
+ if (err instanceof Error && /entity not found/i.test(err.message)) return null;
565
+ throw err;
566
+ }
567
+ const issue = data.issue;
568
+ if (!issue) return null;
569
+ const comments = (await this.#fetchRemainingIssueComments(accessToken, issue.id, issue.comments)).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
570
+ return {
571
+ id: issue.id,
572
+ projectId: issue.project.id,
573
+ identifier: issue.identifier,
574
+ title: issue.title,
575
+ description: issue.description?.trim() ? issue.description : null,
576
+ url: issue.url,
577
+ state: issue.state.name,
578
+ stateType: issue.state.type,
579
+ priorityLabel: issue.priorityLabel,
580
+ assignee: issue.assignee?.name ?? null,
581
+ team: issue.team?.key ?? null,
582
+ labels: issue.labels.nodes.map((label) => label.name),
583
+ createdAt: issue.createdAt,
584
+ updatedAt: issue.updatedAt,
585
+ comments: comments.map((comment) => ({
586
+ author: comment.user?.name ?? null,
587
+ body: comment.body,
588
+ createdAt: comment.createdAt
589
+ }))
590
+ };
591
+ }
592
+ /**
593
+ * Post a comment on an issue. `idOrIdentifier` accepts both the Linear UUID
594
+ * and the human key (`ENG-123`) — the identifier is resolved to a UUID
595
+ * first because `commentCreate` only accepts UUIDs. Returns `null` when the
596
+ * issue doesn't exist.
597
+ */
598
+ async createIssueComment(accessToken, idOrIdentifier, body) {
599
+ let issueId;
600
+ try {
601
+ const data = await linearGraphql(accessToken, `query IssueId($id: String!) { issue(id: $id) { id } }`, { id: idOrIdentifier });
602
+ if (!data.issue) return null;
603
+ issueId = data.issue.id;
604
+ } catch (err) {
605
+ if (err instanceof Error && /entity not found/i.test(err.message)) return null;
606
+ throw err;
607
+ }
608
+ const data = await linearGraphql(accessToken, `mutation CommentCreate($input: CommentCreateInput!) {
1303
609
  commentCreate(input: $input) { success comment { id url } }
1304
- }`,
1305
- { input: { issueId, body } }
1306
- );
1307
- if (!data.commentCreate.success || !data.commentCreate.comment) {
1308
- throw new Error("Linear did not accept the comment.");
1309
- }
1310
- return data.commentCreate.comment;
1311
- }
1312
- // ── FactoryIntegration surface ───────────────────────────────────────────
1313
- /**
1314
- * The integration's HTTP surface: `/web/linear/*` + `/auth/linear/*` Mastra
1315
- * `apiRoutes` (status, OAuth connect/callback, projects + issues for
1316
- * Intake). Handlers operate on this instance.
1317
- */
1318
- routes(ctx) {
1319
- return buildLinearRoutes({
1320
- linear: this,
1321
- auth: ctx.auth,
1322
- stateSigner: ctx.stateSigner,
1323
- baseUrl: ctx.baseUrl,
1324
- intake: ctx.storage.intake,
1325
- ingestFactoryIssues: attachLinearRules(ctx)
1326
- });
1327
- }
1328
- /**
1329
- * Org-scoped agent tools: issue detail + comment tools for sessions whose
1330
- * project belongs to an org with an active Linear connection.
1331
- */
1332
- async agentTools(args) {
1333
- return buildLinearAgentTools({ requestContext: args.requestContext, linear: this });
1334
- }
1335
- /** Non-secret config snapshot for system diagnostics/startup logs. */
1336
- diagnostics() {
1337
- return {
1338
- oauthAppConfigured: true
1339
- };
1340
- }
610
+ }`, { input: {
611
+ issueId,
612
+ body
613
+ } });
614
+ if (!data.commentCreate.success || !data.commentCreate.comment) throw new Error("Linear did not accept the comment.");
615
+ return data.commentCreate.comment;
616
+ }
617
+ /**
618
+ * The integration's HTTP surface: `/web/linear/*` + `/auth/linear/*` Mastra
619
+ * `apiRoutes` (status, OAuth connect/callback, projects + issues for
620
+ * Intake). Handlers operate on this instance.
621
+ */
622
+ routes(ctx) {
623
+ return buildLinearRoutes({
624
+ linear: this,
625
+ auth: ctx.auth,
626
+ stateSigner: ctx.stateSigner,
627
+ baseUrl: ctx.baseUrl,
628
+ intake: ctx.storage.intake,
629
+ ingestFactoryIssues: attachLinearRules(ctx)
630
+ });
631
+ }
632
+ /**
633
+ * Org-scoped agent tools: issue detail + comment tools for sessions whose
634
+ * project belongs to an org with an active Linear connection.
635
+ */
636
+ async agentTools(args) {
637
+ return buildLinearAgentTools({
638
+ requestContext: args.requestContext,
639
+ linear: this
640
+ });
641
+ }
642
+ /** Non-secret config snapshot for system diagnostics/startup logs. */
643
+ diagnostics() {
644
+ return { oauthAppConfigured: true };
645
+ }
1341
646
  };
1342
647
  function getLinearAccessToken(connection) {
1343
- if (connection.type !== "oauth") {
1344
- throw new Error("Linear capabilities require an OAuth connection.");
1345
- }
1346
- return connection.accessToken;
648
+ if (connection.type !== "oauth") throw new Error("Linear capabilities require an OAuth connection.");
649
+ return connection.accessToken;
1347
650
  }
1348
651
  function linearIssueToIntakeIssue(issue) {
1349
- return {
1350
- id: issue.id,
1351
- identifier: issue.identifier,
1352
- title: issue.title,
1353
- url: issue.url,
1354
- author: null,
1355
- state: issue.state,
1356
- stateType: issue.stateType,
1357
- priority: issue.priorityLabel,
1358
- assignee: issue.assignee,
1359
- source: issue.team,
1360
- labels: issue.labels,
1361
- commentCount: null,
1362
- createdAt: issue.createdAt,
1363
- updatedAt: issue.updatedAt
1364
- };
652
+ return {
653
+ id: issue.id,
654
+ identifier: issue.identifier,
655
+ title: issue.title,
656
+ url: issue.url,
657
+ author: null,
658
+ state: issue.state,
659
+ stateType: issue.stateType,
660
+ priority: issue.priorityLabel,
661
+ assignee: issue.assignee,
662
+ source: issue.team,
663
+ labels: issue.labels,
664
+ commentCount: null,
665
+ createdAt: issue.createdAt,
666
+ updatedAt: issue.updatedAt
667
+ };
1365
668
  }
1366
- export {
1367
- LinearIntegration,
1368
- LinearReauthRequiredError
1369
- };
669
+ //#endregion
670
+ export { LinearIntegration, LinearReauthRequiredError };
671
+
1370
672
  //# sourceMappingURL=integration.js.map