@hellcoder/companion 0.96.0

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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,953 @@
1
+ import type { Hono } from "hono";
2
+ import { getSettings } from "../settings-manager.js";
3
+ import { linearCache } from "../linear-cache.js";
4
+ import * as sessionLinearIssues from "../session-linear-issues.js";
5
+ import * as linearProjectManager from "../linear-project-manager.js";
6
+ import { resolveApiKey, getConnection } from "../linear-connections.js";
7
+
8
+ function linearIssueStateCategory(issue: { stateType?: string; stateName?: string }): 0 | 1 | 2 {
9
+ const stateType = (issue.stateType || "").trim().toLowerCase();
10
+ const stateName = (issue.stateName || "").trim().toLowerCase();
11
+ const isDone = stateType === "completed" || stateType === "canceled" || stateType === "cancelled"
12
+ || stateName === "done" || stateName === "completed" || stateName === "canceled" || stateName === "cancelled";
13
+ if (isDone) return 2;
14
+ if (stateType === "started") return 1;
15
+ return 0;
16
+ }
17
+
18
+ /**
19
+ * Transition a Linear issue to a specific workflow state.
20
+ * Returns a result object — never throws.
21
+ */
22
+ export async function transitionLinearIssue(
23
+ issueId: string,
24
+ stateId: string,
25
+ linearApiKey: string,
26
+ connectionId?: string,
27
+ ): Promise<{
28
+ ok: boolean;
29
+ error?: string;
30
+ issue?: { id: string; identifier: string; stateName: string; stateType: string };
31
+ }> {
32
+ try {
33
+ const updateResponse = await fetch("https://api.linear.app/graphql", {
34
+ method: "POST",
35
+ headers: {
36
+ "Content-Type": "application/json",
37
+ Authorization: linearApiKey,
38
+ },
39
+ body: JSON.stringify({
40
+ query: `
41
+ mutation CompanionTransitionIssue($issueId: String!, $stateId: String!) {
42
+ issueUpdate(id: $issueId, input: { stateId: $stateId }) {
43
+ success
44
+ issue {
45
+ id
46
+ identifier
47
+ state { name type }
48
+ }
49
+ }
50
+ }
51
+ `,
52
+ variables: { issueId, stateId },
53
+ }),
54
+ });
55
+
56
+ const updateJson = await updateResponse.json().catch(() => ({})) as {
57
+ data?: {
58
+ issueUpdate?: {
59
+ success?: boolean;
60
+ issue?: {
61
+ id?: string;
62
+ identifier?: string;
63
+ state?: { name?: string; type?: string };
64
+ };
65
+ };
66
+ };
67
+ errors?: Array<{ message?: string }>;
68
+ };
69
+
70
+ if (!updateResponse.ok || (updateJson.errors && updateJson.errors.length > 0)) {
71
+ const errMsg = updateJson.errors?.[0]?.message || updateResponse.statusText || "Failed to update issue state";
72
+ return { ok: false, error: errMsg };
73
+ }
74
+
75
+ const updatedIssue = updateJson.data?.issueUpdate?.issue;
76
+
77
+ // Invalidate cached issue data so the next fetch picks up the new state
78
+ const cachePrefix = connectionId ? `${connectionId}:` : "";
79
+ linearCache.invalidate(`${cachePrefix}issue:${issueId}`);
80
+
81
+ return {
82
+ ok: true,
83
+ issue: {
84
+ id: updatedIssue?.id || issueId,
85
+ identifier: updatedIssue?.identifier || "",
86
+ stateName: updatedIssue?.state?.name || "",
87
+ stateType: updatedIssue?.state?.type || "",
88
+ },
89
+ };
90
+ } catch (e: unknown) {
91
+ const errMsg = e instanceof Error ? e.message : String(e);
92
+ return { ok: false, error: `Linear transition failed: ${errMsg}` };
93
+ }
94
+ }
95
+
96
+ export interface LinearTeamState {
97
+ id: string;
98
+ name: string;
99
+ type: string;
100
+ }
101
+
102
+ export interface LinearTeam {
103
+ id: string;
104
+ key: string;
105
+ name: string;
106
+ states: LinearTeamState[];
107
+ }
108
+
109
+ /**
110
+ * Fetch all Linear team workflow states (cached for 5 minutes).
111
+ * Returns empty array on error.
112
+ */
113
+ export async function fetchLinearTeamStates(linearApiKey: string, cachePrefix?: string): Promise<LinearTeam[]> {
114
+ try {
115
+ const cacheKey = cachePrefix ? `${cachePrefix}:states` : "states";
116
+ return await linearCache.getOrFetch(cacheKey, 300_000, async () => {
117
+ const response = await fetch("https://api.linear.app/graphql", {
118
+ method: "POST",
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ Authorization: linearApiKey,
122
+ },
123
+ body: JSON.stringify({
124
+ query: `
125
+ query CompanionWorkflowStates {
126
+ teams {
127
+ nodes {
128
+ id
129
+ key
130
+ name
131
+ states {
132
+ nodes {
133
+ id
134
+ name
135
+ type
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ `,
142
+ }),
143
+ });
144
+
145
+ const json = await response.json().catch(() => ({})) as {
146
+ data?: {
147
+ teams?: {
148
+ nodes?: Array<{
149
+ id?: string;
150
+ key?: string | null;
151
+ name?: string | null;
152
+ states?: {
153
+ nodes?: Array<{
154
+ id?: string;
155
+ name?: string | null;
156
+ type?: string | null;
157
+ }>;
158
+ };
159
+ }>;
160
+ };
161
+ };
162
+ errors?: Array<{ message?: string }>;
163
+ };
164
+
165
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
166
+ const firstError = json.errors?.[0]?.message || response.statusText || "Linear request failed";
167
+ throw new Error(firstError);
168
+ }
169
+
170
+ return (json.data?.teams?.nodes || []).map((team) => ({
171
+ id: team.id || "",
172
+ key: team.key || "",
173
+ name: team.name || "",
174
+ states: (team.states?.nodes || []).map((state) => ({
175
+ id: state.id || "",
176
+ name: state.name || "",
177
+ type: state.type || "",
178
+ })),
179
+ }));
180
+ });
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ export function registerLinearRoutes(api: Hono): void {
187
+ api.get("/linear/issues", async (c) => {
188
+ const query = (c.req.query("query") || "").trim();
189
+ const limitRaw = Number(c.req.query("limit") || "8");
190
+ const limit = Math.min(20, Math.max(1, Number.isFinite(limitRaw) ? Math.floor(limitRaw) : 8));
191
+ if (!query) return c.json({ issues: [] });
192
+
193
+ const connectionId = c.req.query("connectionId") || undefined;
194
+ const resolved = resolveApiKey(connectionId);
195
+ if (!resolved) {
196
+ return c.json({ error: "No Linear connection configured" }, 400);
197
+ }
198
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
199
+
200
+ try {
201
+ const cacheKey = `${resolvedId}:search:${query}:${limit}`;
202
+ const issues = await linearCache.getOrFetch(cacheKey, 30_000, async () => {
203
+ const response = await fetch("https://api.linear.app/graphql", {
204
+ method: "POST",
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ Authorization: linearApiKey,
208
+ },
209
+ body: JSON.stringify({
210
+ query: `
211
+ query CompanionIssueSearch($term: String!, $first: Int!) {
212
+ searchIssues(term: $term, first: $first) {
213
+ nodes {
214
+ id
215
+ identifier
216
+ title
217
+ description
218
+ url
219
+ branchName
220
+ priorityLabel
221
+ state { name type }
222
+ team { id key name }
223
+ }
224
+ }
225
+ }
226
+ `,
227
+ variables: { term: query, first: limit },
228
+ }),
229
+ }).catch((e: unknown) => {
230
+ throw new Error(`Failed to connect to Linear: ${e instanceof Error ? e.message : String(e)}`);
231
+ });
232
+
233
+ const json = await response.json().catch(() => ({})) as {
234
+ data?: {
235
+ searchIssues?: {
236
+ nodes?: Array<{
237
+ id: string;
238
+ identifier: string;
239
+ title: string;
240
+ description?: string | null;
241
+ url: string;
242
+ branchName?: string | null;
243
+ priorityLabel?: string | null;
244
+ state?: { name?: string | null; type?: string | null } | null;
245
+ team?: { id?: string | null; key?: string | null; name?: string | null } | null;
246
+ }>;
247
+ };
248
+ };
249
+ errors?: Array<{ message?: string }>;
250
+ };
251
+
252
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
253
+ const firstError = json.errors?.[0]?.message || response.statusText || "Linear request failed";
254
+ throw new Error(firstError);
255
+ }
256
+
257
+ return (json.data?.searchIssues?.nodes || []).map((issue) => ({
258
+ id: issue.id,
259
+ identifier: issue.identifier,
260
+ title: issue.title,
261
+ description: issue.description || "",
262
+ url: issue.url,
263
+ branchName: issue.branchName || "",
264
+ priorityLabel: issue.priorityLabel || "",
265
+ stateName: issue.state?.name || "",
266
+ stateType: issue.state?.type || "",
267
+ teamName: issue.team?.name || "",
268
+ teamKey: issue.team?.key || "",
269
+ teamId: issue.team?.id || "",
270
+ }))
271
+ .filter((issue) => linearIssueStateCategory(issue) !== 2)
272
+ .sort((a, b) => linearIssueStateCategory(a) - linearIssueStateCategory(b));
273
+ });
274
+
275
+ return c.json({ issues });
276
+ } catch (e: unknown) {
277
+ return c.json({ error: e instanceof Error ? e.message : "Linear request failed" }, 502);
278
+ }
279
+ });
280
+
281
+ // ─── Create a new Linear issue ──────────────────────────────────────
282
+
283
+ api.post("/linear/issues", async (c) => {
284
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
285
+
286
+ if (typeof body.title !== "string" || !body.title.trim()) {
287
+ return c.json({ error: "title is required" }, 400);
288
+ }
289
+ if (typeof body.teamId !== "string" || !body.teamId.trim()) {
290
+ return c.json({ error: "teamId is required" }, 400);
291
+ }
292
+
293
+ const connectionId = typeof body.connectionId === "string" ? body.connectionId : undefined;
294
+ const resolved = resolveApiKey(connectionId);
295
+ if (!resolved) {
296
+ return c.json({ error: "No Linear connection configured" }, 400);
297
+ }
298
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
299
+
300
+ try {
301
+ const input: Record<string, unknown> = {
302
+ title: (body.title as string).trim(),
303
+ teamId: (body.teamId as string).trim(),
304
+ };
305
+ if (typeof body.description === "string" && body.description.trim()) {
306
+ input.description = body.description.trim();
307
+ }
308
+ if (typeof body.priority === "number" && body.priority >= 0 && body.priority <= 4) {
309
+ input.priority = body.priority;
310
+ }
311
+ if (typeof body.projectId === "string" && body.projectId.trim()) {
312
+ input.projectId = body.projectId.trim();
313
+ }
314
+ if (typeof body.assigneeId === "string" && body.assigneeId.trim()) {
315
+ input.assigneeId = body.assigneeId.trim();
316
+ }
317
+ if (typeof body.stateId === "string" && body.stateId.trim()) {
318
+ input.stateId = body.stateId.trim();
319
+ }
320
+
321
+ const response = await fetch("https://api.linear.app/graphql", {
322
+ method: "POST",
323
+ headers: {
324
+ "Content-Type": "application/json",
325
+ Authorization: linearApiKey,
326
+ },
327
+ body: JSON.stringify({
328
+ query: `
329
+ mutation CompanionCreateIssue($input: IssueCreateInput!) {
330
+ issueCreate(input: $input) {
331
+ success
332
+ issue {
333
+ id
334
+ identifier
335
+ title
336
+ description
337
+ url
338
+ branchName
339
+ priorityLabel
340
+ state { name type }
341
+ team { id key name }
342
+ assignee { name displayName }
343
+ }
344
+ }
345
+ }
346
+ `,
347
+ variables: { input },
348
+ }),
349
+ }).catch((e: unknown) => {
350
+ throw new Error(`Failed to connect to Linear: ${e instanceof Error ? e.message : String(e)}`);
351
+ });
352
+
353
+ const json = await response.json().catch(() => ({})) as {
354
+ data?: {
355
+ issueCreate?: {
356
+ success?: boolean;
357
+ issue?: {
358
+ id: string;
359
+ identifier: string;
360
+ title: string;
361
+ description?: string | null;
362
+ url: string;
363
+ branchName?: string | null;
364
+ priorityLabel?: string | null;
365
+ state?: { name?: string | null; type?: string | null } | null;
366
+ team?: { id?: string | null; key?: string | null; name?: string | null } | null;
367
+ assignee?: { name?: string | null; displayName?: string | null } | null;
368
+ };
369
+ };
370
+ };
371
+ errors?: Array<{ message?: string }>;
372
+ };
373
+
374
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
375
+ const firstError = json.errors?.[0]?.message || response.statusText || "Issue creation failed";
376
+ return c.json({ error: firstError }, 502);
377
+ }
378
+
379
+ const result = json.data?.issueCreate;
380
+ if (!result?.success || !result.issue) {
381
+ return c.json({ error: "Issue creation failed" }, 502);
382
+ }
383
+
384
+ const issue = result.issue;
385
+
386
+ // Invalidate caches so the new issue appears in lists
387
+ if (typeof body.projectId === "string" && body.projectId.trim()) {
388
+ linearCache.invalidate(`${resolvedId}:project-issues:${body.projectId}`);
389
+ }
390
+ linearCache.invalidate(`${resolvedId}:search:`);
391
+
392
+ return c.json({
393
+ ok: true,
394
+ issue: {
395
+ id: issue.id,
396
+ identifier: issue.identifier,
397
+ title: issue.title,
398
+ description: issue.description || "",
399
+ url: issue.url,
400
+ branchName: issue.branchName || "",
401
+ priorityLabel: issue.priorityLabel || "",
402
+ stateName: issue.state?.name || "",
403
+ stateType: issue.state?.type || "",
404
+ teamName: issue.team?.name || "",
405
+ teamKey: issue.team?.key || "",
406
+ teamId: issue.team?.id || "",
407
+ assigneeName: issue.assignee?.displayName || issue.assignee?.name || "",
408
+ },
409
+ });
410
+ } catch (e: unknown) {
411
+ return c.json({ error: e instanceof Error ? e.message : "Issue creation failed" }, 502);
412
+ }
413
+ });
414
+
415
+ api.get("/linear/connection", async (c) => {
416
+ const connectionId = c.req.query("connectionId") || undefined;
417
+ const resolved = resolveApiKey(connectionId);
418
+ if (!resolved) {
419
+ return c.json({ error: "No Linear connection configured" }, 400);
420
+ }
421
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
422
+
423
+ try {
424
+ const result = await linearCache.getOrFetch(`${resolvedId}:connection`, 300_000, async () => {
425
+ const response = await fetch("https://api.linear.app/graphql", {
426
+ method: "POST",
427
+ headers: {
428
+ "Content-Type": "application/json",
429
+ Authorization: linearApiKey,
430
+ },
431
+ body: JSON.stringify({
432
+ query: `
433
+ query CompanionLinearConnection {
434
+ viewer { id name email }
435
+ teams(first: 1) { nodes { id key name } }
436
+ }
437
+ `,
438
+ }),
439
+ }).catch((e: unknown) => {
440
+ throw new Error(`Failed to connect to Linear: ${e instanceof Error ? e.message : String(e)}`);
441
+ });
442
+
443
+ const json = await response.json().catch(() => ({})) as {
444
+ data?: {
445
+ viewer?: { id?: string; name?: string | null; email?: string | null } | null;
446
+ teams?: { nodes?: Array<{ id?: string; key?: string | null; name?: string | null }> } | null;
447
+ };
448
+ errors?: Array<{ message?: string }>;
449
+ };
450
+
451
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
452
+ const firstError = json.errors?.[0]?.message || response.statusText || "Linear request failed";
453
+ throw new Error(firstError);
454
+ }
455
+
456
+ const firstTeam = json.data?.teams?.nodes?.[0];
457
+ return {
458
+ connected: true as const,
459
+ viewerId: json.data?.viewer?.id || "",
460
+ viewerName: json.data?.viewer?.name || "",
461
+ viewerEmail: json.data?.viewer?.email || "",
462
+ teamName: firstTeam?.name || "",
463
+ teamKey: firstTeam?.key || "",
464
+ };
465
+ });
466
+
467
+ return c.json(result);
468
+ } catch (e: unknown) {
469
+ return c.json({ error: e instanceof Error ? e.message : "Linear request failed" }, 502);
470
+ }
471
+ });
472
+
473
+ // ─── Linear issue <-> session association ───────────────────────────
474
+
475
+ api.put("/sessions/:id/linear-issue", async (c) => {
476
+ const id = c.req.param("id");
477
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
478
+ if (!body.id || !body.identifier || !body.title || !body.url) {
479
+ return c.json({ error: "id, identifier, title, and url are required" }, 400);
480
+ }
481
+ sessionLinearIssues.setLinearIssue(id, {
482
+ id: String(body.id),
483
+ identifier: String(body.identifier),
484
+ title: String(body.title),
485
+ description: String(body.description || ""),
486
+ url: String(body.url),
487
+ branchName: String(body.branchName || ""),
488
+ priorityLabel: String(body.priorityLabel || ""),
489
+ stateName: String(body.stateName || ""),
490
+ stateType: String(body.stateType || ""),
491
+ teamName: String(body.teamName || ""),
492
+ teamKey: String(body.teamKey || ""),
493
+ teamId: String(body.teamId || ""),
494
+ assigneeName: body.assigneeName ? String(body.assigneeName) : undefined,
495
+ updatedAt: body.updatedAt ? String(body.updatedAt) : undefined,
496
+ connectionId: body.connectionId ? String(body.connectionId) : undefined,
497
+ });
498
+ return c.json({ ok: true });
499
+ });
500
+
501
+ api.get("/sessions/:id/linear-issue", async (c) => {
502
+ const id = c.req.param("id");
503
+ const stored = sessionLinearIssues.getLinearIssue(id);
504
+ if (!stored) return c.json({ issue: null });
505
+
506
+ const refresh = c.req.query("refresh") === "true";
507
+ if (!refresh) return c.json({ issue: stored });
508
+
509
+ // Fetch fresh data from Linear API using the stored connection
510
+ const resolved = resolveApiKey(stored.connectionId);
511
+ if (!resolved) return c.json({ issue: stored });
512
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
513
+
514
+ try {
515
+ const cacheKey = `${resolvedId}:issue:${stored.id}`;
516
+ const result = await linearCache.getOrFetch(cacheKey, 30_000, async () => {
517
+ const response = await fetch("https://api.linear.app/graphql", {
518
+ method: "POST",
519
+ headers: {
520
+ "Content-Type": "application/json",
521
+ Authorization: linearApiKey,
522
+ },
523
+ body: JSON.stringify({
524
+ query: `
525
+ query CompanionIssueFetch($id: String!) {
526
+ issue(id: $id) {
527
+ id identifier title description url branchName priorityLabel
528
+ state { name type }
529
+ team { id key name }
530
+ comments(last: 5) {
531
+ nodes {
532
+ id body createdAt
533
+ user { id name displayName avatarUrl }
534
+ }
535
+ }
536
+ assignee { id name displayName avatarUrl }
537
+ labels { nodes { id name color } }
538
+ }
539
+ }
540
+ `,
541
+ variables: { id: stored.id },
542
+ }),
543
+ });
544
+
545
+ const json = await response.json().catch(() => ({})) as {
546
+ data?: {
547
+ issue?: {
548
+ id: string;
549
+ identifier: string;
550
+ title: string;
551
+ description?: string | null;
552
+ url: string;
553
+ branchName?: string | null;
554
+ priorityLabel?: string | null;
555
+ state?: { name?: string | null; type?: string | null } | null;
556
+ team?: { id?: string | null; key?: string | null; name?: string | null } | null;
557
+ comments?: { nodes?: Array<{
558
+ id: string;
559
+ body: string;
560
+ createdAt: string;
561
+ user?: { name?: string | null; displayName?: string | null; avatarUrl?: string | null } | null;
562
+ }> } | null;
563
+ assignee?: { name?: string | null; displayName?: string | null; avatarUrl?: string | null } | null;
564
+ labels?: { nodes?: Array<{ id: string; name: string; color: string }> } | null;
565
+ } | null;
566
+ };
567
+ errors?: Array<{ message?: string }>;
568
+ };
569
+
570
+ return json.data?.issue ?? null;
571
+ });
572
+
573
+ if (result) {
574
+ const updated = {
575
+ id: result.id,
576
+ identifier: result.identifier,
577
+ title: result.title,
578
+ description: result.description || "",
579
+ url: result.url,
580
+ branchName: result.branchName || "",
581
+ priorityLabel: result.priorityLabel || "",
582
+ stateName: result.state?.name || "",
583
+ stateType: result.state?.type || "",
584
+ teamName: result.team?.name || "",
585
+ teamKey: result.team?.key || "",
586
+ teamId: result.team?.id || "",
587
+ assigneeName: result.assignee?.displayName || result.assignee?.name || "",
588
+ updatedAt: new Date().toISOString(),
589
+ };
590
+ sessionLinearIssues.setLinearIssue(id, updated);
591
+ return c.json({
592
+ issue: updated,
593
+ comments: (result.comments?.nodes || []).map((comment) => ({
594
+ id: comment.id,
595
+ body: comment.body,
596
+ createdAt: comment.createdAt,
597
+ userName: comment.user?.displayName || comment.user?.name || "Unknown",
598
+ userAvatarUrl: comment.user?.avatarUrl || null,
599
+ })),
600
+ assignee: result.assignee ? {
601
+ name: result.assignee.displayName || result.assignee.name || "",
602
+ avatarUrl: result.assignee.avatarUrl || null,
603
+ } : null,
604
+ labels: (result.labels?.nodes || []).map((l) => ({
605
+ id: l.id,
606
+ name: l.name,
607
+ color: l.color,
608
+ })),
609
+ });
610
+ }
611
+ } catch {
612
+ // Fall through to return stored data on error
613
+ }
614
+
615
+ return c.json({ issue: stored });
616
+ });
617
+
618
+ api.delete("/sessions/:id/linear-issue", (c) => {
619
+ const id = c.req.param("id");
620
+ sessionLinearIssues.removeLinearIssue(id);
621
+ return c.json({ ok: true });
622
+ });
623
+
624
+ api.post("/linear/issues/:issueId/comments", async (c) => {
625
+ const issueId = c.req.param("issueId");
626
+ const body = await c.req.json().catch(() => ({})) as Record<string, unknown>;
627
+ if (typeof body.body !== "string" || !body.body.trim()) {
628
+ return c.json({ error: "body is required" }, 400);
629
+ }
630
+
631
+ const connectionId = typeof body.connectionId === "string" ? body.connectionId : undefined;
632
+ const resolved = resolveApiKey(connectionId);
633
+ if (!resolved) {
634
+ return c.json({ error: "No Linear connection configured" }, 400);
635
+ }
636
+ const { apiKey: linearApiKey, connectionId: resolvedConnId } = resolved;
637
+
638
+ const response = await fetch("https://api.linear.app/graphql", {
639
+ method: "POST",
640
+ headers: {
641
+ "Content-Type": "application/json",
642
+ Authorization: linearApiKey,
643
+ },
644
+ body: JSON.stringify({
645
+ query: `
646
+ mutation CompanionAddComment($issueId: String!, $body: String!) {
647
+ commentCreate(input: { issueId: $issueId, body: $body }) {
648
+ success
649
+ comment { id body createdAt user { name displayName } }
650
+ }
651
+ }
652
+ `,
653
+ variables: { issueId, body: body.body.trim() },
654
+ }),
655
+ }).catch((e: unknown) => {
656
+ throw new Error(`Failed to connect to Linear: ${e instanceof Error ? e.message : String(e)}`);
657
+ });
658
+
659
+ const json = await response.json().catch(() => ({})) as {
660
+ data?: {
661
+ commentCreate?: {
662
+ success?: boolean;
663
+ comment?: {
664
+ id: string;
665
+ body: string;
666
+ createdAt: string;
667
+ user?: { name?: string | null; displayName?: string | null } | null;
668
+ };
669
+ };
670
+ };
671
+ errors?: Array<{ message?: string }>;
672
+ };
673
+
674
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
675
+ const firstError = json.errors?.[0]?.message || response.statusText || "Comment creation failed";
676
+ return c.json({ error: firstError }, 502);
677
+ }
678
+
679
+ const result = json.data?.commentCreate;
680
+ if (!result?.success || !result.comment) {
681
+ return c.json({ error: "Comment creation failed" }, 502);
682
+ }
683
+
684
+ // Invalidate cached issue data so the next poll picks up the new comment
685
+ linearCache.invalidate(`${resolvedConnId}:issue:${issueId}`);
686
+
687
+ return c.json({
688
+ ok: true,
689
+ comment: {
690
+ id: result.comment.id,
691
+ body: result.comment.body,
692
+ createdAt: result.comment.createdAt,
693
+ userName: result.comment.user?.displayName || result.comment.user?.name || "You",
694
+ userAvatarUrl: null,
695
+ },
696
+ });
697
+ });
698
+
699
+ api.get("/linear/states", async (c) => {
700
+ const connectionId = c.req.query("connectionId") || undefined;
701
+ const resolved = resolveApiKey(connectionId);
702
+ if (!resolved) {
703
+ return c.json({ error: "No Linear connection configured" }, 400);
704
+ }
705
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
706
+
707
+ try {
708
+ const teams = await fetchLinearTeamStates(linearApiKey, resolvedId);
709
+ if (teams.length === 0) {
710
+ return c.json({ error: "Failed to fetch Linear workflow states" }, 502);
711
+ }
712
+ return c.json({ teams });
713
+ } catch (e: unknown) {
714
+ return c.json({ error: e instanceof Error ? e.message : "Linear request failed" }, 502);
715
+ }
716
+ });
717
+
718
+ // ─── Linear projects ────────────────────────────────────────────────
719
+
720
+ api.get("/linear/projects", async (c) => {
721
+ const connectionId = c.req.query("connectionId") || undefined;
722
+ const resolved = resolveApiKey(connectionId);
723
+ if (!resolved) {
724
+ return c.json({ error: "No Linear connection configured" }, 400);
725
+ }
726
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
727
+
728
+ try {
729
+ const projects = await linearCache.getOrFetch(`${resolvedId}:projects`, 300_000, async () => {
730
+ const response = await fetch("https://api.linear.app/graphql", {
731
+ method: "POST",
732
+ headers: {
733
+ "Content-Type": "application/json",
734
+ Authorization: linearApiKey,
735
+ },
736
+ body: JSON.stringify({
737
+ query: `
738
+ query CompanionListProjects {
739
+ projects(first: 50, orderBy: updatedAt) {
740
+ nodes { id name state }
741
+ }
742
+ }
743
+ `,
744
+ }),
745
+ }).catch((e: unknown) => {
746
+ throw new Error(`Failed to connect to Linear: ${e instanceof Error ? e.message : String(e)}`);
747
+ });
748
+
749
+ const json = await response.json().catch(() => ({})) as {
750
+ data?: {
751
+ projects?: { nodes?: Array<{ id?: string; name?: string | null; state?: string | null }> } | null;
752
+ };
753
+ errors?: Array<{ message?: string }>;
754
+ };
755
+
756
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
757
+ const firstError = json.errors?.[0]?.message || response.statusText || "Linear request failed";
758
+ throw new Error(firstError);
759
+ }
760
+
761
+ return (json.data?.projects?.nodes || []).map((p) => ({
762
+ id: p.id || "",
763
+ name: p.name || "",
764
+ state: p.state || "",
765
+ }));
766
+ });
767
+
768
+ return c.json({ projects });
769
+ } catch (e: unknown) {
770
+ return c.json({ error: e instanceof Error ? e.message : "Linear request failed" }, 502);
771
+ }
772
+ });
773
+
774
+ // ─── Linear project issues (recent, non-done) ─────────────────────
775
+
776
+ api.get("/linear/project-issues", async (c) => {
777
+ const projectId = (c.req.query("projectId") || "").trim();
778
+ const limitRaw = Number(c.req.query("limit") || "15");
779
+ const limit = Math.min(50, Math.max(1, Number.isFinite(limitRaw) ? Math.floor(limitRaw) : 15));
780
+ if (!projectId) return c.json({ error: "projectId is required" }, 400);
781
+
782
+ const connectionId = c.req.query("connectionId") || undefined;
783
+ const resolved = resolveApiKey(connectionId);
784
+ if (!resolved) {
785
+ return c.json({ error: "No Linear connection configured" }, 400);
786
+ }
787
+ const { apiKey: linearApiKey, connectionId: resolvedId } = resolved;
788
+
789
+ try {
790
+ const cacheKey = `${resolvedId}:project-issues:${projectId}:${limit}`;
791
+ const issues = await linearCache.getOrFetch(cacheKey, 60_000, async () => {
792
+ const response = await fetch("https://api.linear.app/graphql", {
793
+ method: "POST",
794
+ headers: {
795
+ "Content-Type": "application/json",
796
+ Authorization: linearApiKey,
797
+ },
798
+ body: JSON.stringify({
799
+ query: `
800
+ query CompanionProjectIssues($projectId: ID!, $first: Int!) {
801
+ issues(
802
+ filter: {
803
+ project: { id: { eq: $projectId } }
804
+ state: { type: { nin: ["completed", "cancelled"] } }
805
+ }
806
+ orderBy: updatedAt
807
+ first: $first
808
+ ) {
809
+ nodes {
810
+ id
811
+ identifier
812
+ title
813
+ description
814
+ url
815
+ priorityLabel
816
+ state { name type }
817
+ team { key name }
818
+ assignee { name }
819
+ updatedAt
820
+ }
821
+ }
822
+ }
823
+ `,
824
+ variables: { projectId, first: limit },
825
+ }),
826
+ }).catch((e: unknown) => {
827
+ throw new Error(`Failed to connect to Linear: ${e instanceof Error ? e.message : String(e)}`);
828
+ });
829
+
830
+ const json = await response.json().catch(() => ({})) as {
831
+ data?: {
832
+ issues?: {
833
+ nodes?: Array<{
834
+ id: string;
835
+ identifier: string;
836
+ title: string;
837
+ description?: string | null;
838
+ url: string;
839
+ priorityLabel?: string | null;
840
+ state?: { name?: string | null; type?: string | null } | null;
841
+ team?: { key?: string | null; name?: string | null } | null;
842
+ assignee?: { name?: string | null } | null;
843
+ updatedAt?: string | null;
844
+ }>;
845
+ };
846
+ };
847
+ errors?: Array<{ message?: string }>;
848
+ };
849
+
850
+ if (!response.ok || (json.errors && json.errors.length > 0)) {
851
+ const firstError = json.errors?.[0]?.message || response.statusText || "Linear request failed";
852
+ throw new Error(firstError);
853
+ }
854
+
855
+ return (json.data?.issues?.nodes || []).map((issue) => ({
856
+ id: issue.id,
857
+ identifier: issue.identifier,
858
+ title: issue.title,
859
+ description: issue.description || "",
860
+ url: issue.url,
861
+ priorityLabel: issue.priorityLabel || "",
862
+ stateName: issue.state?.name || "",
863
+ stateType: issue.state?.type || "",
864
+ teamName: issue.team?.name || "",
865
+ teamKey: issue.team?.key || "",
866
+ assigneeName: issue.assignee?.name || "",
867
+ updatedAt: issue.updatedAt || "",
868
+ }))
869
+ .filter((issue) => linearIssueStateCategory(issue) !== 2)
870
+ .sort((a, b) => linearIssueStateCategory(a) - linearIssueStateCategory(b));
871
+ });
872
+
873
+ return c.json({ issues });
874
+ } catch (e: unknown) {
875
+ return c.json({ error: e instanceof Error ? e.message : "Linear request failed" }, 502);
876
+ }
877
+ });
878
+
879
+ // ─── Linear project mappings ──────────────────────────────────────
880
+
881
+ api.get("/linear/project-mappings", (c) => {
882
+ const repoRoot = c.req.query("repoRoot");
883
+ if (repoRoot) {
884
+ const mapping = linearProjectManager.getMapping(repoRoot);
885
+ return c.json({ mapping: mapping || null });
886
+ }
887
+ return c.json({ mappings: linearProjectManager.listMappings() });
888
+ });
889
+
890
+ api.put("/linear/project-mappings", async (c) => {
891
+ const body = await c.req.json().catch(() => ({})) as {
892
+ repoRoot?: string;
893
+ projectId?: string;
894
+ projectName?: string;
895
+ };
896
+ if (!body.repoRoot || !body.projectId || !body.projectName) {
897
+ return c.json({ error: "repoRoot, projectId, and projectName are required" }, 400);
898
+ }
899
+ const mapping = linearProjectManager.upsertMapping(body.repoRoot, {
900
+ projectId: body.projectId,
901
+ projectName: body.projectName,
902
+ });
903
+ return c.json({ mapping });
904
+ });
905
+
906
+ api.delete("/linear/project-mappings", async (c) => {
907
+ const body = await c.req.json().catch(() => ({})) as { repoRoot?: string };
908
+ if (!body.repoRoot) return c.json({ error: "repoRoot is required" }, 400);
909
+ const removed = linearProjectManager.removeMapping(body.repoRoot);
910
+ if (!removed) return c.json({ error: "Mapping not found" }, 404);
911
+ return c.json({ ok: true });
912
+ });
913
+
914
+ api.post("/linear/issues/:id/transition", async (c) => {
915
+ const issueId = c.req.param("id");
916
+ if (!issueId) {
917
+ return c.json({ error: "Issue ID is required" }, 400);
918
+ }
919
+
920
+ const reqConnectionId = c.req.query("connectionId") || undefined;
921
+ const resolved = resolveApiKey(reqConnectionId);
922
+ if (!resolved) {
923
+ return c.json({ error: "No Linear connection configured" }, 400);
924
+ }
925
+ const { apiKey: linearApiKey, connectionId: resolvedConnId } = resolved;
926
+
927
+ // Check auto-transition setting: prefer connection-level, fall back to global settings
928
+ const conn = resolvedConnId !== "legacy" ? getConnection(resolvedConnId) : null;
929
+ const autoTransitionEnabled = conn ? conn.autoTransition : getSettings().linearAutoTransition;
930
+ const autoTransitionStateId = conn ? conn.autoTransitionStateId : getSettings().linearAutoTransitionStateId;
931
+
932
+ if (!autoTransitionEnabled) {
933
+ return c.json({ ok: true, skipped: true, reason: "auto_transition_disabled" });
934
+ }
935
+
936
+ const stateId = autoTransitionStateId.trim();
937
+ if (!stateId) {
938
+ return c.json({ ok: true, skipped: true, reason: "no_target_state_configured" });
939
+ }
940
+
941
+ const result = await transitionLinearIssue(issueId, stateId, linearApiKey, resolvedConnId);
942
+ if (!result.ok) {
943
+ return c.json({ error: result.error }, 502);
944
+ }
945
+
946
+ return c.json({
947
+ ok: true,
948
+ skipped: false,
949
+ issue: result.issue,
950
+ });
951
+ });
952
+
953
+ }