-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgithubClient.ts
More file actions
329 lines (290 loc) · 12.2 KB
/
Copy pathgithubClient.ts
File metadata and controls
329 lines (290 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
/**
* Transport layer: the only file that talks to Composio.
*
* server.ts (the product layer) never imports @composio/core directly. It calls
* executeGitHub() and gets back either plain data or a GitHubError whose message
* is safe to read out loud. Everything Composio-shaped — SDK error classes,
* result envelopes, connection state — stops here.
*/
import {
Composio,
ComposioConnectedAccountNotFoundError,
ComposioMultipleConnectedAccountsError,
ComposioRequestCancelledError,
} from "@composio/core";
/** stdout carries the MCP JSON-RPC stream — every log line goes to stderr. */
export const log = (...args: unknown[]) => console.error("[github]", ...args);
/**
* Composio scopes connections by user id. This integration runs on one Mac for
* one person, so there is exactly one — the same id scripts/composio-setup.ts
* connected the GitHub account under.
*/
export const USER_ID = "default";
const TOOLKIT = "github";
/**
* Composio refuses "latest" for manual execution, and it's right to: a toolkit
* release can change a tool's argument schema, and this integration merges pull
* requests. Pinned here, bumped deliberately — never silently, mid-demo.
* Current versions: composio.toolkits.get("github").meta.availableVersions
*/
const TOOLKIT_VERSION = "20260728_00";
/** Reads should feel instant; writes are allowed to take a beat longer. */
export const READ_TIMEOUT_MS = 6_000;
export const WRITE_TIMEOUT_MS = 10_000;
export type FailureKind =
| "setup" // the integration itself isn't configured (no API key, no auth config)
| "not_connected" // no GitHub account linked, or the link was revoked
| "auth_expired" // GitHub rejected the token
| "forbidden" // authenticated, but not allowed to do this
| "not_found" // repo / PR / issue doesn't exist or isn't visible
| "rate_limited"
| "timeout"
| "network"
| "upstream"; // GitHub or Composio failed in a way we don't classify
/**
* The one error type that crosses into server.ts. `kind` drives behaviour
* (not_connected → connect card, everything else → honest failure); `message`
* is written to be spoken to a user, so it never contains a stack or a slug.
*/
export class GitHubError extends Error {
constructor(
readonly kind: FailureKind,
message: string,
) {
super(message);
this.name = "GitHubError";
}
}
let client: Composio | null = null;
/**
* Lazy, not module-level: constructing the client at import time would kill the
* server before VoiceOS could ever start it, and a dead process can't explain
* itself. Built on first use instead, so a missing key surfaces as a sentence.
*/
export function composio(): Composio {
if (client) return client;
const apiKey = process.env.COMPOSIO_API_KEY?.trim();
if (!apiKey) {
throw new GitHubError(
"setup",
"No Composio API key is configured. Add it in the GitHub integration's setup fields.",
);
}
client = new Composio({ apiKey, toolkitVersions: { [TOOLKIT]: TOOLKIT_VERSION } });
return client;
}
/**
* Every GitHub call in this integration goes through here. One funnel means
* timeouts, error classification, and logging are written once and can't drift
* between the five tools.
*/
export async function executeGitHub<T = Record<string, unknown>>(
slug: string,
args: Record<string, unknown> = {},
{ timeoutMs = READ_TIMEOUT_MS }: { timeoutMs?: number } = {},
): Promise<T> {
const startedAt = performance.now();
let result;
try {
result = await composio().tools.execute(
slug,
{ userId: USER_ID, arguments: args },
// A real AbortSignal, not a Promise.race: the SDK passes it to fetch, so
// the HTTP request is actually cancelled instead of left running while we
// walk away from its promise.
{ signal: AbortSignal.timeout(timeoutMs) },
);
} catch (error) {
throw classifyThrown(error, timeoutMs);
}
const ms = Math.round(performance.now() - startedAt);
log(`${slug} ${result.successful ? "ok" : "failed"} in ${ms}ms`);
// Composio reports GitHub-level failures in the envelope rather than by
// throwing, so a successful HTTP round trip still has to be inspected.
if (!result.successful) throw classifyExecutionError(result.error ?? "");
return result.data as T;
}
function classifyThrown(error: unknown, timeoutMs: number): GitHubError {
if (error instanceof GitHubError) return error;
const message = error instanceof Error ? error.message : String(error);
if (
error instanceof ComposioRequestCancelledError ||
(error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"))
) {
const waited = timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${timeoutMs}ms`;
return new GitHubError("timeout", `GitHub didn't respond within ${waited}. Try again in a moment.`);
}
if (error instanceof ComposioConnectedAccountNotFoundError) {
return new GitHubError("not_connected", "No GitHub account is connected yet.");
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|network/i.test(message)) {
return new GitHubError("network", "Couldn't reach GitHub — check your internet connection.");
}
log("unclassified SDK error:", error);
return new GitHubError("upstream", "The GitHub request failed unexpectedly.");
}
/**
* Composio hands GitHub's failure back as a string, so classification is
* substring matching — ordered most-specific first, because "403" appears in
* both rate-limit and permission errors and the rate-limit reading is the one
* with a different fix.
*/
function classifyExecutionError(raw: string): GitHubError {
const text = raw.toLowerCase();
if (/no connected account|connected account not found|not connected/.test(text)) {
return new GitHubError("not_connected", "No GitHub account is connected yet.");
}
if (/rate limit|secondary rate|abuse detection/.test(text)) {
return new GitHubError(
"rate_limited",
"GitHub is rate-limiting this account. Wait a minute and try again.",
);
}
if (/bad credentials|401|unauthorized|token expired|invalid token/.test(text)) {
return new GitHubError(
"auth_expired",
"GitHub rejected the connection — reconnect the GitHub account to refresh access.",
);
}
if (/403|forbidden|permission|not permitted|write access/.test(text)) {
return new GitHubError(
"forbidden",
"The connected GitHub account isn't allowed to do that.",
);
}
if (/404|not found|no such/.test(text)) {
return new GitHubError("not_found", "GitHub couldn't find that repository, pull request, or issue.");
}
log("unclassified execution error:", raw);
return new GitHubError("upstream", `GitHub returned an error: ${firstSentence(raw)}`);
}
/** Keep spoken errors to one sentence — the rest is in the stderr log. */
function firstSentence(text: string): string {
const cleaned = text.replace(/\s+/g, " ").trim();
if (!cleaned) return "no details provided";
const cut = cleaned.slice(0, 160);
return cut.length < cleaned.length ? `${cut}…` : cut;
}
/** True when an ACTIVE GitHub connection exists for this user. */
export async function isConnected(): Promise<boolean> {
const accounts = await composio().connectedAccounts.list({
toolkitSlugs: [TOOLKIT],
userIds: [USER_ID],
});
return accounts.items.some((account) => account.status === "ACTIVE");
}
/**
* The OAuth URL the user visits to link (or relink) GitHub. Only called on the
* failure path, so the happy path never pays for it.
*/
export async function connectUrl(): Promise<string> {
const sdk = composio();
const configs = await sdk.authConfigs.list({ toolkit: TOOLKIT });
const authConfigId = configs.items[0]?.id;
if (!authConfigId) {
throw new GitHubError(
"setup",
"This Composio project has no GitHub auth config yet. Run the setup script once to create it.",
);
}
let request;
try {
request = await sdk.connectedAccounts.link(USER_ID, authConfigId);
} catch (error) {
// link() refuses to issue a second link while an ACTIVE connection exists.
// Reaching this means the connection came back between the failed call and
// now — a retry is the honest advice, not a second dangling OAuth record.
if (error instanceof ComposioMultipleConnectedAccountsError) {
throw new GitHubError(
"upstream",
"GitHub is already connected for this account. Try the request again.",
);
}
throw error;
}
if (!request.redirectUrl) {
throw new GitHubError("setup", "Composio didn't return a GitHub authorization link.");
}
return request.redirectUrl;
}
export interface Repo {
owner: string;
repo: string;
fullName: string;
}
/** "Vibe DJ", "vibe-dj", "vibedj" — one spoken name, many transcriptions. */
const normalize = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, "");
/**
* Composio wraps most list responses in an envelope named after the resource:
* GITHUB_LIST_PULL_REQUESTS → { pull_requests: [...] }, reviews → { reviews:
* [...] }, and so on. Callers pass the key(s) they expect; if none match, the
* one array-valued key in the envelope is taken, so a renamed envelope degrades
* to still-working instead of silently-empty — the failure mode that once made
* every inbox look like inbox zero.
*/
export function extractList(data: unknown, ...preferredKeys: string[]): Array<Record<string, unknown>> {
if (Array.isArray(data)) return data as Array<Record<string, unknown>>;
if (!data || typeof data !== "object") return [];
const record = data as Record<string, unknown>;
for (const key of [...preferredKeys, "items", "data", "results"]) {
if (Array.isArray(record[key])) return record[key] as Array<Record<string, unknown>>;
}
const arrayKeys = Object.keys(record).filter((key) => Array.isArray(record[key]));
if (arrayKeys.length === 1) return record[arrayKeys[0]] as Array<Record<string, unknown>>;
return [];
}
/**
* Turn what the user said into owner/repo.
*
* People say "merge the vibe-dj PR", never "merge arav/vibe-dj#4", so a bare
* name has to resolve against the repositories this account can actually see.
* A wrong guess here silently acts on someone else's repository, so ambiguity
* is an error, never a coin flip.
*/
export async function resolveRepo(spoken: string): Promise<Repo> {
const raw = spoken.trim();
if (!raw) throw new GitHubError("not_found", "No repository was named.");
// Already qualified: trust it and let GitHub 404 if it's wrong. Verifying it
// here would cost an extra round trip on the common, correct case.
if (raw.includes("/")) {
const [owner, repo] = raw.split("/").map((part) => part.trim());
if (!owner || !repo) {
throw new GitHubError("not_found", `"${raw}" isn't a valid owner/repository name.`);
}
return { owner, repo, fullName: `${owner}/${repo}` };
}
const data = await executeGitHub("GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER", {
sort: "updated",
per_page: 100,
});
const candidates: Repo[] = extractList(data, "repositories")
.map((item) => ({
repo: String(item.name ?? ""),
owner: String((item.owner as Record<string, unknown>)?.login ?? ""),
fullName: String(item.full_name ?? ""),
}))
.filter((item) => item.repo && item.owner);
const target = normalize(raw);
const exact = candidates.filter((item) => normalize(item.repo) === target);
if (exact.length === 1) return exact[0];
if (exact.length > 1) throw ambiguous(raw, exact);
const partial = candidates.filter((item) => {
const name = normalize(item.repo);
return name.includes(target) || target.includes(name);
});
if (partial.length === 1) return partial[0];
if (partial.length > 1) throw ambiguous(raw, partial);
const nearest = candidates.slice(0, 3).map((item) => item.repo);
throw new GitHubError(
"not_found",
`No repository named "${raw}" among the ${candidates.length} repositories this account can see.` +
(nearest.length ? ` Most recently updated: ${nearest.join(", ")}.` : ""),
);
}
function ambiguous(spoken: string, matches: Repo[]): GitHubError {
const names = matches.slice(0, 4).map((item) => item.fullName || `${item.owner}/${item.repo}`);
return new GitHubError(
"not_found",
`"${spoken}" matches more than one repository: ${names.join(", ")}. Say the owner too.`,
);
}