feat(api): GitHub REST compat shim at /api/v3 — top 20 endpoint groups #5514
4 changed files+2288−0
Addedsrc/__tests__/github-compat.test.ts+590−0View fileUnifiedSplit
@@ -0,0 +1,590 @@
1/**
2 * GitHub compat shim (/api/v3) — route-level guards follow the api-v2 test
3 * idiom (app.request, statuses tolerant of a missing DB), shape mappers are
4 * pure-function unit tests that need neither DB nor git.
5 */
6
7import { describe, it, expect, beforeAll, afterAll } from "bun:test";
8import { join } from "path";
9import { rm, mkdir } from "fs/promises";
10import app from "../app";
11import { clearRateLimitStore } from "../middleware/rate-limit";
12import {
13 ghUser,
14 ghUserSummary,
15 ghRepo,
16 ghBranch,
17 ghTag,
18 ghRef,
19 ghCommit,
20 ghDiffFile,
21 ghContentFile,
22 ghContentDirEntry,
23 ghLabel,
24 ghIssue,
25 ghComment,
26 ghPull,
27 parsePagination,
28 paginateArray,
29 buildLinkHeader,
30} from "../lib/github-compat-shapes";
31
32const TEST_REPOS = join(import.meta.dir, "../../.test-repos-github-compat");
33
34beforeAll(async () => {
35 process.env.GIT_REPOS_PATH = TEST_REPOS;
36 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
37 clearRateLimitStore();
38 await mkdir(TEST_REPOS, { recursive: true });
39});
40
41afterAll(async () => {
42 await rm(TEST_REPOS, { recursive: true, force: true });
43});
44
45const BASE = "https://gluecron.com";
46const CTX = { baseUrl: BASE, owner: "alice", repo: "widgets" };
47
48function v3(path: string): string {
49 if (path === "/") return "/api/v3";
50 return `/api/v3${path}`;
51}
52
53/**
54 * Writes are refused by the shim's pre-gate auth middleware, which runs
55 * BEFORE the DB-touching privacy gate — so the 401 (in GitHub error shape)
56 * is deterministic whether or not a DB is reachable.
57 */
58async function expectAuthGuard(res: Response) {
59 expect(res.status).toBe(401);
60 const body = await res.json();
61 expect(body.message).toBeDefined();
62 expect(body.documentation_url).toBeDefined();
63 expect(body.error).toBeUndefined();
64}
65
66function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
67 return { "Content-Type": "application/json", ...extra };
68}
69
70// ---------------------------------------------------------------------------
71// Shape mappers (pure — no DB)
72// ---------------------------------------------------------------------------
73
74describe("github-compat shapes - users", () => {
75 const u = {
76 id: "u-1",
77 username: "alice",
78 displayName: "Alice",
79 bio: "hi",
80 avatarUrl: null,
81 email: "alice@example.com",
82 createdAt: new Date("2026-01-02T03:04:05Z"),
83 };
84
85 it("ghUserSummary emits GitHub field names", () => {
86 const out = ghUserSummary(u, BASE);
87 expect(out.login).toBe("alice");
88 expect(out.id).toBe("u-1");
89 expect(out.type).toBe("User");
90 expect(out.html_url).toBe("https://gluecron.com/alice");
91 expect(out.site_admin).toBe(false);
92 expect(out.avatar_url).toContain("alice");
93 });
94
95 it("ghUser withholds email unless asked", () => {
96 expect(ghUser(u, BASE).email).toBeNull();
97 expect(ghUser(u, BASE, { includeEmail: true }).email).toBe("alice@example.com");
98 expect(ghUser(u, BASE).created_at).toBe("2026-01-02T03:04:05.000Z");
99 expect(ghUser(u, BASE).name).toBe("Alice");
100 });
101});
102
103describe("github-compat shapes - repos", () => {
104 const owner = { id: "u-1", username: "alice" };
105 const repo = {
106 id: "r-1",
107 name: "widgets",
108 description: "desc",
109 isPrivate: true,
110 isArchived: false,
111 defaultBranch: "trunk",
112 forkedFromId: null,
113 createdAt: new Date("2026-01-01T00:00:00Z"),
114 updatedAt: new Date("2026-02-01T00:00:00Z"),
115 pushedAt: null,
116 starCount: 7,
117 forkCount: 2,
118 issueCount: 3,
119 };
120
121 it("ghRepo emits full_name, owner.login, snake_case counters", () => {
122 const out = ghRepo(repo, owner, BASE);
123 expect(out.full_name).toBe("alice/widgets");
124 expect(out.private).toBe(true);
125 expect(out.owner.login).toBe("alice");
126 expect(out.default_branch).toBe("trunk");
127 expect(out.stargazers_count).toBe(7);
128 expect(out.forks_count).toBe(2);
129 expect(out.open_issues_count).toBe(3);
130 expect(out.clone_url).toBe("https://gluecron.com/alice/widgets.git");
131 expect(out.ssh_url).toBe("git@gluecron.com:alice/widgets.git");
132 expect(out.html_url).toBe("https://gluecron.com/alice/widgets");
133 expect(out.visibility).toBe("private");
134 expect(out.fork).toBe(false);
135 expect(out.created_at).toBe("2026-01-01T00:00:00.000Z");
136 expect(out.pushed_at).toBeNull();
137 });
138
139 it("ghRepo default-branch override wins over the row value", () => {
140 expect(ghRepo(repo, owner, BASE, "main").default_branch).toBe("main");
141 });
142});
143
144describe("github-compat shapes - git objects", () => {
145 it("ghBranch has commit.sha and protected", () => {
146 const out = ghBranch("main", "a".repeat(40), CTX);
147 expect(out.name).toBe("main");
148 expect(out.commit.sha).toBe("a".repeat(40));
149 expect(out.protected).toBe(false);
150 });
151
152 it("ghTag carries zipball/tarball urls", () => {
153 const out = ghTag({ name: "v1.0.0", sha: "b".repeat(40) }, CTX);
154 expect(out.name).toBe("v1.0.0");
155 expect(out.commit.sha).toBe("b".repeat(40));
156 expect(out.zipball_url).toContain("archive/v1.0.0.zip");
157 });
158
159 it("ghRef matches the git-refs object shape", () => {
160 const out = ghRef("feature/x", "c".repeat(40), CTX);
161 expect(out.ref).toBe("refs/heads/feature/x");
162 expect(out.object.sha).toBe("c".repeat(40));
163 expect(out.object.type).toBe("commit");
164 });
165
166 it("ghCommit nests author under commit and lists parents", () => {
167 const out = ghCommit(
168 {
169 sha: "d".repeat(40),
170 message: "fix: things",
171 author: "Alice",
172 authorEmail: "alice@example.com",
173 date: "2026-03-01T00:00:00+00:00",
174 parentShas: ["e".repeat(40)],
175 },
176 CTX
177 );
178 expect(out.sha).toBe("d".repeat(40));
179 expect(out.commit.message).toBe("fix: things");
180 expect(out.commit.author.name).toBe("Alice");
181 expect(out.commit.author.email).toBe("alice@example.com");
182 expect(out.parents).toHaveLength(1);
183 expect(out.parents[0].sha).toBe("e".repeat(40));
184 expect(out.html_url).toContain("/commit/");
185 });
186
187 it("ghDiffFile maps to filename/changes and translates statuses", () => {
188 const out = ghDiffFile({
189 path: "src/a.ts",
190 status: "A",
191 additions: 3,
192 deletions: 1,
193 patch: "@@",
194 });
195 expect(out.filename).toBe("src/a.ts");
196 expect(out.status).toBe("added");
197 expect(out.changes).toBe(4);
198 expect(
199 ghDiffFile({ path: "x", status: "modified", additions: 0, deletions: 0, patch: "" })
200 .status
201 ).toBe("modified");
202 });
203});
204
205describe("github-compat shapes - contents", () => {
206 it("ghContentFile is a base64 file entity", () => {
207 const out = ghContentFile(
208 { path: "docs/readme.md", size: 5, sha: "f".repeat(40), contentBase64: "aGVsbG8=" },
209 CTX,
210 "main"
211 );
212 expect(out.type).toBe("file");
213 expect(out.encoding).toBe("base64");
214 expect(out.content).toBe("aGVsbG8=");
215 expect(out.name).toBe("readme.md");
216 expect(out.path).toBe("docs/readme.md");
217 expect(out.sha).toBe("f".repeat(40));
218 expect(out.url).toContain("ref=main");
219 });
220
221 it("ghContentDirEntry maps tree→dir and joins the path", () => {
222 const dir = ghContentDirEntry(
223 { name: "lib", type: "tree", sha: "1".repeat(40) },
224 "src",
225 CTX,
226 null
227 );
228 expect(dir.type).toBe("dir");
229 expect(dir.path).toBe("src/lib");
230 expect(dir.download_url).toBeNull();
231
232 const file = ghContentDirEntry(
233 { name: "a.ts", type: "blob", sha: "2".repeat(40), size: 12 },
234 "",
235 CTX,
236 "dev"
237 );
238 expect(file.type).toBe("file");
239 expect(file.path).toBe("a.ts");
240 expect(file.size).toBe(12);
241 expect(file.download_url).toContain("/raw/dev/a.ts");
242 });
243});
244
245describe("github-compat shapes - issues / pulls / labels", () => {
246 const author = { id: "u-1", username: "alice" };
247
248 it("ghLabel strips the # from color", () => {
249 const out = ghLabel({ id: "l-1", name: "bug", color: "#ff0000" }, CTX);
250 expect(out.color).toBe("ff0000");
251 expect(out.name).toBe("bug");
252 expect(out.default).toBe(false);
253 });
254
255 it("ghIssue emits number/state/user.login/labels/comments", () => {
256 const out = ghIssue(
257 {
258 id: "i-1",
259 number: 42,
260 title: "It breaks",
261 body: null,
262 state: "closed",
263 createdAt: new Date("2026-01-01T00:00:00Z"),
264 updatedAt: new Date("2026-01-02T00:00:00Z"),
265 closedAt: new Date("2026-01-03T00:00:00Z"),
266 },
267 author,
268 [{ id: "l-1", name: "bug", color: "#ff0000" }],
269 CTX,
270 5
271 );
272 expect(out.number).toBe(42);
273 expect(out.state).toBe("closed");
274 expect(out.user.login).toBe("alice");
275 expect(out.labels[0].name).toBe("bug");
276 expect(out.comments).toBe(5);
277 expect(out.closed_at).toBe("2026-01-03T00:00:00.000Z");
278 expect(out.html_url).toBe("https://gluecron.com/alice/widgets/issues/42");
279 });
280
281 it("ghComment carries body/user/created_at", () => {
282 const out = ghComment(
283 { id: "c-1", body: "nice", createdAt: new Date("2026-01-01T00:00:00Z") },
284 author,
285 42,
286 CTX
287 );
288 expect(out.body).toBe("nice");
289 expect(out.user.login).toBe("alice");
290 expect(out.created_at).toBe("2026-01-01T00:00:00.000Z");
291 });
292
293 it("ghPull maps merged state to closed+merged:true", () => {
294 const merged = ghPull(
295 {
296 id: "p-1",
297 number: 7,
298 title: "Ship it",
299 state: "merged",
300 baseBranch: "main",
301 headBranch: "feat/x",
302 mergedAt: new Date("2026-01-05T00:00:00Z"),
303 isDraft: false,
304 },
305 author,
306 CTX,
307 "9".repeat(40)
308 );
309 expect(merged.state).toBe("closed");
310 expect(merged.merged).toBe(true);
311 expect(merged.merged_at).toBe("2026-01-05T00:00:00.000Z");
312 expect(merged.head.ref).toBe("feat/x");
313 expect(merged.head.sha).toBe("9".repeat(40));
314 expect(merged.base.ref).toBe("main");
315
316 const open = ghPull(
317 {
318 id: "p-2",
319 number: 8,
320 title: "WIP",
321 state: "open",
322 baseBranch: "main",
323 headBranch: "feat/y",
324 isDraft: true,
325 },
326 author,
327 CTX
328 );
329 expect(open.state).toBe("open");
330 expect(open.merged).toBe(false);
331 expect(open.draft).toBe(true);
332 expect(open.head.sha).toBeNull();
333 });
334});
335
336describe("github-compat shapes - pagination", () => {
337 it("parsePagination defaults 30/1 and caps at 100", () => {
338 expect(parsePagination(undefined, undefined)).toEqual({ perPage: 30, page: 1, offset: 0 });
339 expect(parsePagination("500", "3")).toEqual({ perPage: 100, page: 3, offset: 200 });
340 expect(parsePagination("-2", "0")).toEqual({ perPage: 30, page: 1, offset: 0 });
341 expect(parsePagination("abc", "xyz")).toEqual({ perPage: 30, page: 1, offset: 0 });
342 });
343
344 it("paginateArray slices and reports prev/next", () => {
345 const items = Array.from({ length: 7 }, (_, i) => i);
346 const p1 = paginateArray(items, { perPage: 3, page: 1, offset: 0 });
347 expect(p1.slice).toEqual([0, 1, 2]);
348 expect(p1.hasPrev).toBe(false);
349 expect(p1.hasNext).toBe(true);
350 const p3 = paginateArray(items, { perPage: 3, page: 3, offset: 6 });
351 expect(p3.slice).toEqual([6]);
352 expect(p3.hasPrev).toBe(true);
353 expect(p3.hasNext).toBe(false);
354 });
355
356 it("buildLinkHeader emits rel next/prev with rewritten params", () => {
357 const link = buildLinkHeader(
358 "https://gluecron.com/api/v3/repos/a/b/issues?state=open&page=2&per_page=10",
359 { perPage: 10, page: 2, offset: 10 },
360 { hasPrev: true, hasNext: true }
361 );
362 expect(link).toContain('rel="prev"');
363 expect(link).toContain('rel="next"');
364 expect(link).toContain("page=1");
365 expect(link).toContain("page=3");
366 expect(link).toContain("state=open");
367 expect(
368 buildLinkHeader("https://x/api/v3/user/repos", { perPage: 30, page: 1, offset: 0 }, { hasPrev: false, hasNext: false })
369 ).toBeNull();
370 });
371});
372
373// ---------------------------------------------------------------------------
374// Route-level (api-v2 idiom: DB may be absent — accept 404/500 where a
375// lookup would run; auth guards must answer regardless)
376// ---------------------------------------------------------------------------
377
378describe("github-compat routes - meta", () => {
379 it("GET /api/v3/ returns hypermedia root with GitHub media-type header", async () => {
380 const res = await app.request(v3("/"));
381 expect(res.status).toBe(200);
382 expect(res.headers.get("X-GitHub-Media-Type")).toBe("github.v3; format=json");
383 const body = await res.json();
384 expect(body.current_user_url).toContain("/api/v3/user");
385 expect(body.rate_limit_url).toContain("/api/v3/rate_limit");
386 });
387
388 it("GET /api/v3/rate_limit returns resources.core with hourly reset", async () => {
389 const res = await app.request(v3("/rate_limit"));
390 expect(res.status).toBe(200);
391 const body = await res.json();
392 expect(body.resources.core.limit).toBeGreaterThan(0);
393 expect(body.resources.core.reset).toBeGreaterThan(Math.floor(Date.now() / 1000));
394 expect(body.rate.limit).toBe(body.resources.core.limit);
395 });
396
397 it("emits X-RateLimit-* headers", async () => {
398 const res = await app.request(v3("/rate_limit"));
399 expect(parseInt(res.headers.get("X-RateLimit-Limit") || "0")).toBeGreaterThan(0);
400 expect(res.headers.get("X-RateLimit-Remaining")).not.toBeNull();
401 expect(res.headers.get("X-RateLimit-Reset")).not.toBeNull();
402 });
403
404 it("unknown /api/v3 path 404s in GitHub JSON shape, not HTML", async () => {
405 const res = await app.request(v3("/no/such/endpoint"));
406 expect(res.status).toBe(404);
407 expect(res.headers.get("Content-Type") || "").toContain("application/json");
408 const body = await res.json();
409 expect(body.message).toBe("Not Found");
410 expect(body.documentation_url).toBeDefined();
411 });
412});
413
414describe("github-compat routes - auth translation", () => {
415 it("GET /user without auth returns 401 in GitHub error shape", async () => {
416 const res = await app.request(v3("/user"));
417 expect(res.status).toBe(401);
418 const body = await res.json();
419 expect(body.message).toBeDefined();
420 expect(body.documentation_url).toBeDefined();
421 expect(body.error).toBeUndefined();
422 });
423
424 it("Authorization: token <bad> is routed through PAT auth (401, GitHub shape)", async () => {
425 const res = await app.request(v3("/user"), {
426 headers: { Authorization: "token not-a-real-pat" },
427 });
428 expect(res.status).toBe(401);
429 const body = await res.json();
430 // apiAuth's {error} was rewritten to GitHub's {message}.
431 expect(body.message).toBeDefined();
432 expect(body.error).toBeUndefined();
433 });
434
435 it("Authorization: Bearer <bad> behaves identically", async () => {
436 const res = await app.request(v3("/user"), {
437 headers: { Authorization: "Bearer not-a-real-pat" },
438 });
439 expect(res.status).toBe(401);
440 const body = await res.json();
441 expect(body.message).toBeDefined();
442 });
443});
444
445describe("github-compat routes - repos", () => {
446 it("GET /repos/:owner/:repo returns 404 or 500 (no DB) in JSON", async () => {
447 const res = await app.request(v3("/repos/nobody/nothing"));
448 expect([404, 500]).toContain(res.status);
449 });
450
451 it("GET /users/nobody returns 404 or 500", async () => {
452 const res = await app.request(v3("/users/nobody"));
453 expect([404, 500]).toContain(res.status);
454 });
455
456 it("GET /users/nobody/repos returns 404 or 500", async () => {
457 const res = await app.request(v3("/users/nobody/repos"));
458 expect([404, 500]).toContain(res.status);
459 });
460
461 it("GET /user/repos without auth returns 401", async () => {
462 const res = await app.request(v3("/user/repos"));
463 expect(res.status).toBe(401);
464 });
465
466 it("GET /repos/nobody/nothing/branches returns 404 or 500", async () => {
467 const res = await app.request(v3("/repos/nobody/nothing/branches"));
468 expect([404, 500]).toContain(res.status);
469 });
470
471 it("GET /repos/nobody/nothing/tags returns 404 or 500", async () => {
472 const res = await app.request(v3("/repos/nobody/nothing/tags"));
473 expect([404, 500]).toContain(res.status);
474 });
475
476 it("GET /repos/nobody/nothing/commits returns 404 or 500", async () => {
477 const res = await app.request(v3("/repos/nobody/nothing/commits"));
478 expect([404, 500]).toContain(res.status);
479 });
480
481 it("GET /repos/nobody/nothing/contents/README.md returns 404 or 500", async () => {
482 const res = await app.request(v3("/repos/nobody/nothing/contents/README.md"));
483 expect([404, 500]).toContain(res.status);
484 });
485
486 it("GET /repos/nobody/nothing/git/refs/heads/main returns 404 or 500", async () => {
487 const res = await app.request(v3("/repos/nobody/nothing/git/refs/heads/main"));
488 expect([404, 500]).toContain(res.status);
489 });
490
491 it("GET /repos/nobody/nothing/git/ref/heads/main returns 404 or 500", async () => {
492 const res = await app.request(v3("/repos/nobody/nothing/git/ref/heads/main"));
493 expect([404, 500]).toContain(res.status);
494 });
495
496 it("GET /repos/nobody/nothing/labels returns 404 or 500", async () => {
497 const res = await app.request(v3("/repos/nobody/nothing/labels"));
498 expect([404, 500]).toContain(res.status);
499 });
500});
501
502describe("github-compat routes - write guards", () => {
503 it("PUT contents without auth is refused", async () => {
504 const res = await app.request(v3("/repos/nobody/nothing/contents/a.txt"), {
505 method: "PUT",
506 headers: jsonHeaders(),
507 body: JSON.stringify({ message: "m", content: "aGk=" }),
508 });
509 await expectAuthGuard(res);
510 });
511
512 it("POST issues without auth is refused", async () => {
513 const res = await app.request(v3("/repos/nobody/nothing/issues"), {
514 method: "POST",
515 headers: jsonHeaders(),
516 body: JSON.stringify({ title: "Bug" }),
517 });
518 await expectAuthGuard(res);
519 });
520
521 it("PATCH issues/:number without auth is refused", async () => {
522 const res = await app.request(v3("/repos/nobody/nothing/issues/1"), {
523 method: "PATCH",
524 headers: jsonHeaders(),
525 body: JSON.stringify({ state: "closed" }),
526 });
527 await expectAuthGuard(res);
528 });
529
530 it("POST issue comments without auth is refused", async () => {
531 const res = await app.request(v3("/repos/nobody/nothing/issues/1/comments"), {
532 method: "POST",
533 headers: jsonHeaders(),
534 body: JSON.stringify({ body: "hi" }),
535 });
536 await expectAuthGuard(res);
537 });
538
539 it("POST pulls without auth is refused", async () => {
540 const res = await app.request(v3("/repos/nobody/nothing/pulls"), {
541 method: "POST",
542 headers: jsonHeaders(),
543 body: JSON.stringify({ title: "PR", head: "feat", base: "main" }),
544 });
545 await expectAuthGuard(res);
546 });
547
548 it("PATCH pulls/:number without auth is refused", async () => {
549 const res = await app.request(v3("/repos/nobody/nothing/pulls/1"), {
550 method: "PATCH",
551 headers: jsonHeaders(),
552 body: JSON.stringify({ state: "closed" }),
553 });
554 await expectAuthGuard(res);
555 });
556
557 it("PUT pulls/:number/merge without auth is refused (GitHub shape when 401)", async () => {
558 const res = await app.request(v3("/repos/nobody/nothing/pulls/1/merge"), {
559 method: "PUT",
560 });
561 await expectAuthGuard(res);
562 });
563});
564
565describe("github-compat routes - reads that tolerate missing DB", () => {
566 it("GET issues list returns 404 or 500", async () => {
567 const res = await app.request(v3("/repos/nobody/nothing/issues"));
568 expect([404, 500]).toContain(res.status);
569 });
570
571 it("GET issues/:number returns 404 or 500", async () => {
572 const res = await app.request(v3("/repos/nobody/nothing/issues/1"));
573 expect([404, 500]).toContain(res.status);
574 });
575
576 it("GET issue comments returns 404 or 500", async () => {
577 const res = await app.request(v3("/repos/nobody/nothing/issues/1/comments"));
578 expect([404, 500]).toContain(res.status);
579 });
580
581 it("GET pulls list returns 404 or 500", async () => {
582 const res = await app.request(v3("/repos/nobody/nothing/pulls"));
583 expect([404, 500]).toContain(res.status);
584 });
585
586 it("GET pulls/:number returns 404 or 500", async () => {
587 const res = await app.request(v3("/repos/nobody/nothing/pulls/1"));
588 expect([404, 500]).toContain(res.status);
589 });
590});
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
@@ -13,6 +13,7 @@ import gitRoutes from "./routes/git";
1313import lfsRoutes from "./routes/lfs";
1414import apiRoutes from "./routes/api";
1515import apiV2Routes from "./routes/api-v2";
16import githubCompatRoutes from "./routes/github-compat";
1617import apiDocsRoutes from "./routes/api-docs";
1718import buildAgentSpecRoutes from "./routes/build-agent-spec";
1819import pullsDashboardRoutes from "./routes/pulls-dashboard";
@@ -575,6 +576,10 @@ app.route("/", demoRoutes);
575576// REST API v2 (basePath /api/v2)
576577app.route("/", apiV2Routes);
577578
579// GitHub REST v3 compatibility shim (translation layer over the same libs;
580// /api/v3 is the GitHub Enterprise base-URL convention).
581app.route("/api/v3", githubCompatRoutes);
582
578583// Agent multiplayer v1 — /api/v2/agents/* (sessions, leases, usage).
579584// Mounted alongside apiV2Routes (its own basePath, no path conflict).
580585app.route("/", agentsRoutes);
Addedsrc/lib/github-compat-shapes.ts+523−0View fileUnifiedSplit
@@ -0,0 +1,523 @@
1/**
2 * GitHub REST v3 shape mappers for the compatibility shim
3 * (src/routes/github-compat.ts). Pure functions — row/struct in, GitHub
4 * JSON out — so they unit-test without a DB or git.
5 *
6 * Known, deliberate divergences from api.github.com:
7 * - `id` / `node_id` are Gluecron UUIDs, not integers. Anything comparing
8 * ids for equality works; arithmetic on ids does not.
9 * - Issues and PRs number from separate sequences (GitHub shares one).
10 * - `zipball_url` / `tarball_url` follow GitHub's URL pattern but archive
11 * downloads are not guaranteed to be served.
12 */
13
14export interface GhCtx {
15 /** e.g. https://gluecron.com — no trailing slash. */
16 baseUrl: string;
17 owner: string;
18 repo: string;
19}
20
21const iso = (d: Date | string | null | undefined): string | null => {
22 if (!d) return null;
23 const date = d instanceof Date ? d : new Date(d);
24 return isNaN(date.getTime()) ? null : date.toISOString();
25};
26
27const api = (baseUrl: string, path: string) => `${baseUrl}/api/v3${path}`;
28
29// ─── Users ──────────────────────────────────────────────────────────────────
30
31export interface GhUserSource {
32 id: string;
33 username: string;
34 displayName?: string | null;
35 bio?: string | null;
36 avatarUrl?: string | null;
37 email?: string | null;
38 createdAt?: Date | string | null;
39 updatedAt?: Date | string | null;
40}
41
42export function ghUserSummary(u: GhUserSource, baseUrl: string) {
43 return {
44 login: u.username,
45 id: u.id,
46 node_id: u.id,
47 avatar_url: u.avatarUrl || `${baseUrl}/avatar/${u.username}`,
48 url: api(baseUrl, `/users/${u.username}`),
49 html_url: `${baseUrl}/${u.username}`,
50 type: "User" as const,
51 site_admin: false,
52 };
53}
54
55export function ghUser(u: GhUserSource, baseUrl: string, opts?: { includeEmail?: boolean }) {
56 return {
57 ...ghUserSummary(u, baseUrl),
58 name: u.displayName ?? null,
59 email: opts?.includeEmail ? (u.email ?? null) : null,
60 bio: u.bio ?? null,
61 created_at: iso(u.createdAt),
62 updated_at: iso(u.updatedAt ?? u.createdAt),
63 };
64}
65
66// ─── Repositories ───────────────────────────────────────────────────────────
67
68export interface GhRepoSource {
69 id: string;
70 name: string;
71 description?: string | null;
72 isPrivate?: boolean;
73 isArchived?: boolean;
74 defaultBranch?: string | null;
75 forkedFromId?: string | null;
76 createdAt?: Date | string | null;
77 updatedAt?: Date | string | null;
78 pushedAt?: Date | string | null;
79 starCount?: number;
80 forkCount?: number;
81 issueCount?: number;
82}
83
84export function ghRepo(
85 repo: GhRepoSource,
86 owner: GhUserSource,
87 baseUrl: string,
88 defaultBranchOverride?: string | null
89) {
90 const fullName = `${owner.username}/${repo.name}`;
91 const host = (() => {
92 try {
93 return new URL(baseUrl).host;
94 } catch {
95 return baseUrl;
96 }
97 })();
98 return {
99 id: repo.id,
100 node_id: repo.id,
101 name: repo.name,
102 full_name: fullName,
103 private: !!repo.isPrivate,
104 owner: ghUserSummary(owner, baseUrl),
105 html_url: `${baseUrl}/${fullName}`,
106 url: api(baseUrl, `/repos/${fullName}`),
107 description: repo.description ?? null,
108 fork: !!repo.forkedFromId,
109 clone_url: `${baseUrl}/${fullName}.git`,
110 git_url: `${baseUrl}/${fullName}.git`,
111 ssh_url: `git@${host}:${fullName}.git`,
112 default_branch: defaultBranchOverride || repo.defaultBranch || "main",
113 created_at: iso(repo.createdAt),
114 updated_at: iso(repo.updatedAt),
115 pushed_at: iso(repo.pushedAt),
116 stargazers_count: repo.starCount ?? 0,
117 watchers_count: repo.starCount ?? 0,
118 forks_count: repo.forkCount ?? 0,
119 forks: repo.forkCount ?? 0,
120 open_issues_count: repo.issueCount ?? 0,
121 open_issues: repo.issueCount ?? 0,
122 archived: !!repo.isArchived,
123 disabled: false,
124 visibility: repo.isPrivate ? "private" : "public",
125 has_issues: true,
126 has_pull_requests: true,
127 };
128}
129
130// ─── Branches / tags / refs ─────────────────────────────────────────────────
131
132export function ghBranch(
133 name: string,
134 sha: string,
135 ctx: GhCtx,
136 opts?: { isProtected?: boolean }
137) {
138 const full = `${ctx.owner}/${ctx.repo}`;
139 return {
140 name,
141 commit: {
142 sha,
143 url: api(ctx.baseUrl, `/repos/${full}/commits/${sha}`),
144 },
145 protected: !!opts?.isProtected,
146 };
147}
148
149export function ghTag(
150 tag: { name: string; sha: string },
151 ctx: GhCtx
152) {
153 const full = `${ctx.owner}/${ctx.repo}`;
154 return {
155 name: tag.name,
156 commit: {
157 sha: tag.sha,
158 url: api(ctx.baseUrl, `/repos/${full}/commits/${tag.sha}`),
159 },
160 zipball_url: `${ctx.baseUrl}/${full}/archive/${tag.name}.zip`,
161 tarball_url: `${ctx.baseUrl}/${full}/archive/${tag.name}.tar.gz`,
162 node_id: tag.sha,
163 };
164}
165
166export function ghRef(branch: string, sha: string, ctx: GhCtx) {
167 const full = `${ctx.owner}/${ctx.repo}`;
168 const ref = `refs/heads/${branch}`;
169 return {
170 ref,
171 node_id: sha,
172 url: api(ctx.baseUrl, `/repos/${full}/git/${ref}`),
173 object: {
174 sha,
175 type: "commit" as const,
176 url: api(ctx.baseUrl, `/repos/${full}/git/commits/${sha}`),
177 },
178 };
179}
180
181// ─── Commits ────────────────────────────────────────────────────────────────
182
183export interface GhCommitSource {
184 sha: string;
185 message: string;
186 author: string;
187 authorEmail: string;
188 date: string;
189 parentShas: string[];
190}
191
192export function ghCommit(commit: GhCommitSource, ctx: GhCtx) {
193 const full = `${ctx.owner}/${ctx.repo}`;
194 const person = {
195 name: commit.author,
196 email: commit.authorEmail,
197 date: commit.date,
198 };
199 return {
200 sha: commit.sha,
201 node_id: commit.sha,
202 url: api(ctx.baseUrl, `/repos/${full}/commits/${commit.sha}`),
203 html_url: `${ctx.baseUrl}/${full}/commit/${commit.sha}`,
204 commit: {
205 author: person,
206 committer: person,
207 message: commit.message,
208 comment_count: 0,
209 },
210 // Commit identities are free-text git author lines — not resolvable to
211 // platform accounts here, so these stay null (GitHub does the same for
212 // unmatched emails).
213 author: null,
214 committer: null,
215 parents: commit.parentShas.map((sha) => ({
216 sha,
217 url: api(ctx.baseUrl, `/repos/${full}/commits/${sha}`),
218 })),
219 };
220}
221
222const DIFF_STATUS: Record<string, string> = {
223 A: "added",
224 M: "modified",
225 D: "removed",
226 R: "renamed",
227 C: "copied",
228};
229
230export function ghDiffFile(f: {
231 path: string;
232 oldPath?: string;
233 status: string;
234 additions: number;
235 deletions: number;
236 patch: string;
237}) {
238 return {
239 filename: f.path,
240 previous_filename: f.oldPath,
241 status: DIFF_STATUS[f.status] || f.status || "modified",
242 additions: f.additions,
243 deletions: f.deletions,
244 changes: f.additions + f.deletions,
245 patch: f.patch || undefined,
246 };
247}
248
249// ─── Contents ───────────────────────────────────────────────────────────────
250
251function contentUrls(path: string, ref: string | null, ctx: GhCtx) {
252 const full = `${ctx.owner}/${ctx.repo}`;
253 const refQ = ref ? `?ref=${encodeURIComponent(ref)}` : "";
254 return {
255 url: api(ctx.baseUrl, `/repos/${full}/contents/${path}${refQ}`),
256 html_url: `${ctx.baseUrl}/${full}/blob/${ref || "HEAD"}/${path}`,
257 download_url: `${ctx.baseUrl}/${full}/raw/${ref || "HEAD"}/${path}`,
258 };
259}
260
261export function ghContentFile(
262 f: { path: string; size: number; sha: string; contentBase64: string },
263 ctx: GhCtx,
264 ref: string | null
265) {
266 const name = f.path.split("/").pop() || f.path;
267 const urls = contentUrls(f.path, ref, ctx);
268 return {
269 type: "file" as const,
270 encoding: "base64" as const,
271 size: f.size,
272 name,
273 path: f.path,
274 content: f.contentBase64,
275 sha: f.sha,
276 ...urls,
277 git_url: api(
278 ctx.baseUrl,
279 `/repos/${ctx.owner}/${ctx.repo}/git/blobs/${f.sha}`
280 ),
281 _links: { self: urls.url, html: urls.html_url, git: null },
282 };
283}
284
285export function ghContentDirEntry(
286 entry: { name: string; type: "blob" | "tree" | "commit"; sha: string; size?: number },
287 dirPath: string,
288 ctx: GhCtx,
289 ref: string | null
290) {
291 const path = dirPath ? `${dirPath}/${entry.name}` : entry.name;
292 const type =
293 entry.type === "tree" ? "dir" : entry.type === "commit" ? "submodule" : "file";
294 const urls = contentUrls(path, ref, ctx);
295 return {
296 type,
297 size: entry.size ?? 0,
298 name: entry.name,
299 path,
300 sha: entry.sha,
301 url: urls.url,
302 html_url: urls.html_url,
303 download_url: type === "file" ? urls.download_url : null,
304 git_url: null,
305 };
306}
307
308// ─── Labels ─────────────────────────────────────────────────────────────────
309
310export interface GhLabelSource {
311 id: string;
312 name: string;
313 color?: string | null;
314 description?: string | null;
315}
316
317export function ghLabel(label: GhLabelSource, ctx: GhCtx) {
318 return {
319 id: label.id,
320 node_id: label.id,
321 url: api(
322 ctx.baseUrl,
323 `/repos/${ctx.owner}/${ctx.repo}/labels/${encodeURIComponent(label.name)}`
324 ),
325 name: label.name,
326 // GitHub colors carry no '#'.
327 color: (label.color || "8b949e").replace(/^#/, ""),
328 description: label.description ?? null,
329 default: false,
330 };
331}
332
333// ─── Issues / comments ──────────────────────────────────────────────────────
334
335export interface GhIssueSource {
336 id: string;
337 number: number;
338 title: string;
339 body?: string | null;
340 state: string;
341 createdAt?: Date | string | null;
342 updatedAt?: Date | string | null;
343 closedAt?: Date | string | null;
344}
345
346export function ghIssue(
347 issue: GhIssueSource,
348 author: GhUserSource,
349 labelRows: GhLabelSource[],
350 ctx: GhCtx,
351 commentCount = 0
352) {
353 const full = `${ctx.owner}/${ctx.repo}`;
354 return {
355 id: issue.id,
356 node_id: issue.id,
357 number: issue.number,
358 title: issue.title,
359 body: issue.body ?? null,
360 state: issue.state === "closed" ? "closed" : "open",
361 user: ghUserSummary(author, ctx.baseUrl),
362 labels: labelRows.map((l) => ghLabel(l, ctx)),
363 assignee: null,
364 assignees: [],
365 milestone: null,
366 locked: false,
367 comments: commentCount,
368 url: api(ctx.baseUrl, `/repos/${full}/issues/${issue.number}`),
369 html_url: `${ctx.baseUrl}/${full}/issues/${issue.number}`,
370 created_at: iso(issue.createdAt),
371 updated_at: iso(issue.updatedAt),
372 closed_at: iso(issue.closedAt),
373 };
374}
375
376export interface GhCommentSource {
377 id: string;
378 body: string;
379 createdAt?: Date | string | null;
380 updatedAt?: Date | string | null;
381}
382
383export function ghComment(
384 comment: GhCommentSource,
385 author: GhUserSource,
386 issueNumber: number,
387 ctx: GhCtx
388) {
389 const full = `${ctx.owner}/${ctx.repo}`;
390 return {
391 id: comment.id,
392 node_id: comment.id,
393 body: comment.body,
394 user: ghUserSummary(author, ctx.baseUrl),
395 url: api(ctx.baseUrl, `/repos/${full}/issues/comments/${comment.id}`),
396 html_url: `${ctx.baseUrl}/${full}/issues/${issueNumber}#comment-${comment.id}`,
397 created_at: iso(comment.createdAt),
398 updated_at: iso(comment.updatedAt),
399 };
400}
401
402// ─── Pull requests ──────────────────────────────────────────────────────────
403
404export interface GhPullSource {
405 id: string;
406 number: number;
407 title: string;
408 body?: string | null;
409 /** Gluecron states: open | closed | merged. */
410 state: string;
411 isDraft?: boolean;
412 baseBranch: string;
413 headBranch: string;
414 mergedAt?: Date | string | null;
415 createdAt?: Date | string | null;
416 updatedAt?: Date | string | null;
417 closedAt?: Date | string | null;
418}
419
420export function ghPull(
421 pr: GhPullSource,
422 author: GhUserSource,
423 ctx: GhCtx,
424 headSha: string | null = null,
425 baseSha: string | null = null
426) {
427 const full = `${ctx.owner}/${ctx.repo}`;
428 const merged = pr.state === "merged" || !!pr.mergedAt;
429 return {
430 id: pr.id,
431 node_id: pr.id,
432 number: pr.number,
433 title: pr.title,
434 body: pr.body ?? null,
435 // GitHub PRs are only ever open/closed; merged is closed + merged:true.
436 state: pr.state === "open" ? "open" : "closed",
437 merged,
438 draft: !!pr.isDraft,
439 locked: false,
440 user: ghUserSummary(author, ctx.baseUrl),
441 head: {
442 ref: pr.headBranch,
443 label: `${ctx.owner}:${pr.headBranch}`,
444 sha: headSha,
445 },
446 base: {
447 ref: pr.baseBranch,
448 label: `${ctx.owner}:${pr.baseBranch}`,
449 sha: baseSha,
450 },
451 url: api(ctx.baseUrl, `/repos/${full}/pulls/${pr.number}`),
452 html_url: `${ctx.baseUrl}/${full}/pulls/${pr.number}`,
453 created_at: iso(pr.createdAt),
454 updated_at: iso(pr.updatedAt),
455 closed_at: iso(pr.closedAt),
456 merged_at: iso(pr.mergedAt),
457 assignee: null,
458 assignees: [],
459 requested_reviewers: [],
460 labels: [],
461 milestone: null,
462 };
463}
464
465// ─── Pagination ─────────────────────────────────────────────────────────────
466
467export interface GhPagination {
468 perPage: number;
469 page: number;
470 offset: number;
471}
472
473/** GitHub semantics: per_page default 30 cap 100, page 1-based. */
474export function parsePagination(
475 perPageRaw: string | undefined,
476 pageRaw: string | undefined
477): GhPagination {
478 let perPage = parseInt(perPageRaw || "30", 10);
479 if (!Number.isFinite(perPage) || perPage < 1) perPage = 30;
480 if (perPage > 100) perPage = 100;
481 let page = parseInt(pageRaw || "1", 10);
482 if (!Number.isFinite(page) || page < 1) page = 1;
483 return { perPage, page, offset: (page - 1) * perPage };
484}
485
486export function paginateArray<T>(
487 items: T[],
488 p: GhPagination
489): { slice: T[]; hasPrev: boolean; hasNext: boolean } {
490 return {
491 slice: items.slice(p.offset, p.offset + p.perPage),
492 hasPrev: p.page > 1,
493 hasNext: p.offset + p.perPage < items.length,
494 };
495}
496
497/**
498 * RFC 5988 Link header for octokit pagination. `requestUrl` is the full
499 * request URL; per_page/page params are rewritten per relation. Returns
500 * null when there is nothing to link.
501 */
502export function buildLinkHeader(
503 requestUrl: string,
504 p: GhPagination,
505 rels: { hasPrev: boolean; hasNext: boolean }
506): string | null {
507 let url: URL;
508 try {
509 url = new URL(requestUrl, "http://localhost");
510 } catch {
511 return null;
512 }
513 const linkFor = (page: number) => {
514 const u = new URL(url.toString());
515 u.searchParams.set("per_page", String(p.perPage));
516 u.searchParams.set("page", String(page));
517 return u.toString();
518 };
519 const parts: string[] = [];
520 if (rels.hasPrev) parts.push(`<${linkFor(p.page - 1)}>; rel="prev"`);
521 if (rels.hasNext) parts.push(`<${linkFor(p.page + 1)}>; rel="next"`);
522 return parts.length ? parts.join(", ") : null;
523}
Addedsrc/routes/github-compat.ts+1170−0View fileUnifiedSplit
Large file (1,171 lines). Load full file
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts