@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
}
|