Commitdaf8526unknown_key
Merge branch 'main' into claude/setup-multi-repo-dev-BCwNQ
29 files changed+4862−2423daf8526e2e584d93f6f49803f8b28852f6dd3529
29 changed files+4862−2423
Addedsrc/__tests__/api-v2.test.ts+644−0View fileUnifiedSplit
@@ -0,0 +1,644 @@
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import app from "../app";
5import { clearRateLimitStore } from "../middleware/rate-limit";
6
7const TEST_REPOS = join(import.meta.dir, "../../.test-repos-api-v2");
8
9beforeAll(async () => {
10 process.env.GIT_REPOS_PATH = TEST_REPOS;
11 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
12 clearRateLimitStore();
13 await mkdir(TEST_REPOS, { recursive: true });
14});
15
16afterAll(async () => {
17 await rm(TEST_REPOS, { recursive: true, force: true });
18});
19
20// ---------------------------------------------------------------------------
21// Helpers
22// ---------------------------------------------------------------------------
23
24function apiUrl(path: string): string {
25 if (path === "/") return "/api/v2";
26 return `/api/v2${path}`;
27}
28
29function jsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
30 return { "Content-Type": "application/json", ...extra };
31}
32
33function bearerHeader(token: string): Record<string, string> {
34 return { Authorization: `Bearer ${token}` };
35}
36
37// ---------------------------------------------------------------------------
38// 1. API Info endpoint
39// ---------------------------------------------------------------------------
40
41describe("API v2 - Info endpoint", () => {
42 it("GET /api/v2/ returns 200 with JSON listing all endpoints", async () => {
43 const res = await app.request(apiUrl("/"));
44 expect(res.status).toBe(200);
45
46 const body = await res.json();
47 expect(body.name).toBe("gluecron API");
48 expect(body.version).toBe("2.0");
49 expect(body.endpoints).toBeDefined();
50 expect(body.endpoints.users).toBeDefined();
51 expect(body.endpoints.repositories).toBeDefined();
52 expect(body.endpoints.branches).toBeDefined();
53 expect(body.endpoints.commits).toBeDefined();
54 expect(body.endpoints.files).toBeDefined();
55 expect(body.endpoints.issues).toBeDefined();
56 expect(body.endpoints.pullRequests).toBeDefined();
57 expect(body.endpoints.stars).toBeDefined();
58 expect(body.endpoints.labels).toBeDefined();
59 expect(body.endpoints.search).toBeDefined();
60 expect(body.endpoints.topics).toBeDefined();
61 expect(body.endpoints.webhooks).toBeDefined();
62 expect(body.endpoints.activity).toBeDefined();
63 expect(body.authentication).toBeDefined();
64 expect(body.rateLimit).toBeDefined();
65 });
66});
67
68// ---------------------------------------------------------------------------
69// 2. User endpoints
70// ---------------------------------------------------------------------------
71
72describe("API v2 - User endpoints", () => {
73 it("GET /api/v2/user without auth returns 401", async () => {
74 const res = await app.request(apiUrl("/user"));
75 expect(res.status).toBe(401);
76
77 const body = await res.json();
78 expect(body.error).toBeDefined();
79 });
80
81 it("GET /api/v2/users/nobody returns 404 or 500 (no DB)", async () => {
82 const res = await app.request(apiUrl("/users/nobody"));
83 expect([404, 500]).toContain(res.status);
84 });
85});
86
87// ---------------------------------------------------------------------------
88// 3. Repository endpoints
89// ---------------------------------------------------------------------------
90
91describe("API v2 - Repository endpoints", () => {
92 it("POST /api/v2/repos without auth returns 401", async () => {
93 const res = await app.request(apiUrl("/repos"), {
94 method: "POST",
95 headers: jsonHeaders(),
96 body: JSON.stringify({ name: "test-repo" }),
97 });
98 expect(res.status).toBe(401);
99
100 const body = await res.json();
101 expect(body.error).toBeDefined();
102 });
103
104 it("GET /api/v2/repos/nobody/nothing returns 404 or 500 (no DB)", async () => {
105 const res = await app.request(apiUrl("/repos/nobody/nothing"));
106 expect([404, 500]).toContain(res.status);
107 });
108
109 it("PATCH /api/v2/repos/nobody/nothing without auth returns 401", async () => {
110 const res = await app.request(apiUrl("/repos/nobody/nothing"), {
111 method: "PATCH",
112 headers: jsonHeaders(),
113 body: JSON.stringify({ description: "updated" }),
114 });
115 expect(res.status).toBe(401);
116
117 const body = await res.json();
118 expect(body.error).toBeDefined();
119 });
120
121 it("DELETE /api/v2/repos/nobody/nothing without auth returns 401", async () => {
122 const res = await app.request(apiUrl("/repos/nobody/nothing"), {
123 method: "DELETE",
124 });
125 expect(res.status).toBe(401);
126
127 const body = await res.json();
128 expect(body.error).toBeDefined();
129 });
130});
131
132// ---------------------------------------------------------------------------
133// 4. Branch endpoints
134// ---------------------------------------------------------------------------
135
136describe("API v2 - Branch endpoints", () => {
137 it("GET /api/v2/repos/nobody/nothing/branches returns 404 or 500", async () => {
138 const res = await app.request(apiUrl("/repos/nobody/nothing/branches"));
139 expect([404, 500]).toContain(res.status);
140 });
141});
142
143// ---------------------------------------------------------------------------
144// 5. Commit endpoints
145// ---------------------------------------------------------------------------
146
147describe("API v2 - Commit endpoints", () => {
148 it("GET /api/v2/repos/nobody/nothing/commits returns 404 or 500", async () => {
149 const res = await app.request(apiUrl("/repos/nobody/nothing/commits"));
150 expect([404, 500]).toContain(res.status);
151 });
152
153 it("GET /api/v2/repos/nobody/nothing/commits/abc123 returns 404 or 500", async () => {
154 const res = await app.request(
155 apiUrl("/repos/nobody/nothing/commits/abc123")
156 );
157 expect([404, 500]).toContain(res.status);
158 });
159});
160
161// ---------------------------------------------------------------------------
162// 6. Tree / File endpoints
163// ---------------------------------------------------------------------------
164
165describe("API v2 - Tree and File endpoints", () => {
166 it("GET /api/v2/repos/nobody/nothing/tree/main returns 404 or 500", async () => {
167 const res = await app.request(
168 apiUrl("/repos/nobody/nothing/tree/main")
169 );
170 expect([404, 500]).toContain(res.status);
171 });
172
173 it("GET /api/v2/repos/nobody/nothing/contents/README.md returns 404 or 500", async () => {
174 const res = await app.request(
175 apiUrl("/repos/nobody/nothing/contents/README.md")
176 );
177 expect([404, 500]).toContain(res.status);
178 });
179});
180
181// ---------------------------------------------------------------------------
182// 7. Issue endpoints
183// ---------------------------------------------------------------------------
184
185describe("API v2 - Issue endpoints", () => {
186 it("GET /api/v2/repos/nobody/nothing/issues returns 404 or 500 (no DB)", async () => {
187 const res = await app.request(
188 apiUrl("/repos/nobody/nothing/issues")
189 );
190 expect([404, 500]).toContain(res.status);
191 });
192
193 it("POST /api/v2/repos/nobody/nothing/issues without auth returns 401", async () => {
194 const res = await app.request(
195 apiUrl("/repos/nobody/nothing/issues"),
196 {
197 method: "POST",
198 headers: jsonHeaders(),
199 body: JSON.stringify({ title: "Bug report" }),
200 }
201 );
202 expect(res.status).toBe(401);
203
204 const body = await res.json();
205 expect(body.error).toBeDefined();
206 });
207});
208
209// ---------------------------------------------------------------------------
210// 8. Pull Request endpoints
211// ---------------------------------------------------------------------------
212
213describe("API v2 - Pull Request endpoints", () => {
214 it("GET /api/v2/repos/nobody/nothing/pulls returns 404 or 500 (no DB)", async () => {
215 const res = await app.request(
216 apiUrl("/repos/nobody/nothing/pulls")
217 );
218 expect([404, 500]).toContain(res.status);
219 });
220
221 it("POST /api/v2/repos/nobody/nothing/pulls without auth returns 401", async () => {
222 const res = await app.request(
223 apiUrl("/repos/nobody/nothing/pulls"),
224 {
225 method: "POST",
226 headers: jsonHeaders(),
227 body: JSON.stringify({
228 title: "Feature branch",
229 baseBranch: "main",
230 headBranch: "feature",
231 }),
232 }
233 );
234 expect(res.status).toBe(401);
235
236 const body = await res.json();
237 expect(body.error).toBeDefined();
238 });
239});
240
241// ---------------------------------------------------------------------------
242// 9. Star endpoints
243// ---------------------------------------------------------------------------
244
245describe("API v2 - Star endpoints", () => {
246 it("PUT /api/v2/repos/nobody/nothing/star without auth returns 401", async () => {
247 const res = await app.request(
248 apiUrl("/repos/nobody/nothing/star"),
249 { method: "PUT" }
250 );
251 expect(res.status).toBe(401);
252
253 const body = await res.json();
254 expect(body.error).toBeDefined();
255 });
256
257 it("DELETE /api/v2/repos/nobody/nothing/star without auth returns 401", async () => {
258 const res = await app.request(
259 apiUrl("/repos/nobody/nothing/star"),
260 { method: "DELETE" }
261 );
262 expect(res.status).toBe(401);
263
264 const body = await res.json();
265 expect(body.error).toBeDefined();
266 });
267});
268
269// ---------------------------------------------------------------------------
270// 10. Label endpoints
271// ---------------------------------------------------------------------------
272
273describe("API v2 - Label endpoints", () => {
274 it("GET /api/v2/repos/nobody/nothing/labels returns 404 or 500 (no DB)", async () => {
275 const res = await app.request(
276 apiUrl("/repos/nobody/nothing/labels")
277 );
278 expect([404, 500]).toContain(res.status);
279 });
280
281 it("POST /api/v2/repos/nobody/nothing/labels without auth returns 401", async () => {
282 const res = await app.request(
283 apiUrl("/repos/nobody/nothing/labels"),
284 {
285 method: "POST",
286 headers: jsonHeaders(),
287 body: JSON.stringify({ name: "bug", color: "#ff0000" }),
288 }
289 );
290 expect(res.status).toBe(401);
291
292 const body = await res.json();
293 expect(body.error).toBeDefined();
294 });
295});
296
297// ---------------------------------------------------------------------------
298// 11. Search endpoints
299// ---------------------------------------------------------------------------
300
301describe("API v2 - Search endpoints", () => {
302 it("GET /api/v2/search/repos without query returns 400", async () => {
303 const res = await app.request(apiUrl("/search/repos"));
304 expect(res.status).toBe(400);
305
306 const body = await res.json();
307 expect(body.error).toContain("required");
308 });
309
310 it("GET /api/v2/search/repos?q=test returns 200 or 500 (no DB)", async () => {
311 const res = await app.request(apiUrl("/search/repos?q=test"));
312 expect([200, 500]).toContain(res.status);
313 });
314
315 it("GET /api/v2/repos/nobody/nothing/search/code?q=test returns 404 or 500", async () => {
316 const res = await app.request(
317 apiUrl("/repos/nobody/nothing/search/code?q=test")
318 );
319 expect([404, 500]).toContain(res.status);
320 });
321
322 it("GET /api/v2/search/repos with empty q returns 400", async () => {
323 const res = await app.request(apiUrl("/search/repos?q="));
324 expect(res.status).toBe(400);
325
326 const body = await res.json();
327 expect(body.error).toBeDefined();
328 });
329
330 it("GET /api/v2/repos/nobody/nothing/search/code without q returns 400", async () => {
331 const res = await app.request(
332 apiUrl("/repos/nobody/nothing/search/code")
333 );
334 expect(res.status).toBe(400);
335
336 const body = await res.json();
337 expect(body.error).toContain("required");
338 });
339});
340
341// ---------------------------------------------------------------------------
342// 12. Topic endpoints
343// ---------------------------------------------------------------------------
344
345describe("API v2 - Topic endpoints", () => {
346 it("GET /api/v2/repos/nobody/nothing/topics returns 404 or 500 (no DB)", async () => {
347 const res = await app.request(
348 apiUrl("/repos/nobody/nothing/topics")
349 );
350 expect([404, 500]).toContain(res.status);
351 });
352
353 it("PUT /api/v2/repos/nobody/nothing/topics without auth returns 401", async () => {
354 const res = await app.request(
355 apiUrl("/repos/nobody/nothing/topics"),
356 {
357 method: "PUT",
358 headers: jsonHeaders(),
359 body: JSON.stringify({ topics: ["javascript", "web"] }),
360 }
361 );
362 expect(res.status).toBe(401);
363
364 const body = await res.json();
365 expect(body.error).toBeDefined();
366 });
367});
368
369// ---------------------------------------------------------------------------
370// 13. Webhook endpoints
371// ---------------------------------------------------------------------------
372
373describe("API v2 - Webhook endpoints", () => {
374 it("GET /api/v2/repos/nobody/nothing/webhooks without auth returns 401", async () => {
375 const res = await app.request(
376 apiUrl("/repos/nobody/nothing/webhooks")
377 );
378 expect(res.status).toBe(401);
379
380 const body = await res.json();
381 expect(body.error).toBeDefined();
382 });
383
384 it("POST /api/v2/repos/nobody/nothing/webhooks without auth returns 401", async () => {
385 const res = await app.request(
386 apiUrl("/repos/nobody/nothing/webhooks"),
387 {
388 method: "POST",
389 headers: jsonHeaders(),
390 body: JSON.stringify({ url: "https://example.com/hook" }),
391 }
392 );
393 expect(res.status).toBe(401);
394
395 const body = await res.json();
396 expect(body.error).toBeDefined();
397 });
398});
399
400// ---------------------------------------------------------------------------
401// 14. Activity endpoint
402// ---------------------------------------------------------------------------
403
404describe("API v2 - Activity endpoint", () => {
405 it("GET /api/v2/repos/nobody/nothing/activity returns 404 or 500 (no DB)", async () => {
406 const res = await app.request(
407 apiUrl("/repos/nobody/nothing/activity")
408 );
409 expect([404, 500]).toContain(res.status);
410 });
411});
412
413// ---------------------------------------------------------------------------
414// 15. Rate limiting
415// ---------------------------------------------------------------------------
416
417describe("API v2 - Rate limiting", () => {
418 it("responses include X-RateLimit-Limit header", async () => {
419 const res = await app.request(apiUrl("/"));
420 expect(res.status).toBe(200);
421
422 const limitHeader = res.headers.get("X-RateLimit-Limit");
423 expect(limitHeader).toBeDefined();
424 expect(limitHeader).not.toBeNull();
425 expect(parseInt(limitHeader!)).toBeGreaterThan(0);
426 });
427
428 it("responses include X-RateLimit-Remaining header", async () => {
429 const res = await app.request(apiUrl("/"));
430 expect(res.status).toBe(200);
431
432 const remainingHeader = res.headers.get("X-RateLimit-Remaining");
433 expect(remainingHeader).toBeDefined();
434 expect(remainingHeader).not.toBeNull();
435 expect(parseInt(remainingHeader!)).toBeGreaterThanOrEqual(0);
436 });
437
438 it("responses include X-RateLimit-Reset header", async () => {
439 const res = await app.request(apiUrl("/"));
440 expect(res.status).toBe(200);
441
442 const resetHeader = res.headers.get("X-RateLimit-Reset");
443 expect(resetHeader).toBeDefined();
444 expect(resetHeader).not.toBeNull();
445 expect(parseInt(resetHeader!)).toBeGreaterThan(0);
446 });
447});
448
449// ---------------------------------------------------------------------------
450// 16. Invalid Bearer token
451// ---------------------------------------------------------------------------
452
453describe("API v2 - Invalid Bearer token", () => {
454 it("GET /api/v2/user with invalid Bearer token returns 401", async () => {
455 const res = await app.request(apiUrl("/user"), {
456 headers: bearerHeader("invalid-token-that-does-not-exist"),
457 });
458 expect(res.status).toBe(401);
459
460 const body = await res.json();
461 expect(body.error).toBeDefined();
462 });
463
464 it("GET /api/v2/user with malformed Bearer header returns 401", async () => {
465 const res = await app.request(apiUrl("/user"), {
466 headers: bearerHeader(""),
467 });
468 expect(res.status).toBe(401);
469
470 const body = await res.json();
471 expect(body.error).toBeDefined();
472 });
473});
474
475// ---------------------------------------------------------------------------
476// 17. JSON content type
477// ---------------------------------------------------------------------------
478
479describe("API v2 - JSON content type", () => {
480 it("API info endpoint returns application/json", async () => {
481 const res = await app.request(apiUrl("/"));
482 expect(res.status).toBe(200);
483
484 const contentType = res.headers.get("Content-Type");
485 expect(contentType).toBeDefined();
486 expect(contentType!).toContain("application/json");
487 });
488
489 it("401 responses return application/json", async () => {
490 const res = await app.request(apiUrl("/user"));
491 expect(res.status).toBe(401);
492
493 const contentType = res.headers.get("Content-Type");
494 expect(contentType).toBeDefined();
495 expect(contentType!).toContain("application/json");
496 });
497
498 it("400 responses return application/json", async () => {
499 const res = await app.request(apiUrl("/search/repos"));
500 expect(res.status).toBe(400);
501
502 const contentType = res.headers.get("Content-Type");
503 expect(contentType).toBeDefined();
504 expect(contentType!).toContain("application/json");
505 });
506});
507
508// ---------------------------------------------------------------------------
509// 18. Validation - POST /api/v2/repos with auth header but no body fields
510// ---------------------------------------------------------------------------
511
512describe("API v2 - Repo creation validation", () => {
513 it("POST /api/v2/repos with auth header but no body returns 400 or 401", async () => {
514 const res = await app.request(apiUrl("/repos"), {
515 method: "POST",
516 headers: jsonHeaders(bearerHeader("fake-token-for-validation-test")),
517 body: JSON.stringify({}),
518 });
519 // Without a valid token the auth middleware rejects first (401),
520 // but if auth somehow passes, missing name would be 400.
521 expect([400, 401]).toContain(res.status);
522
523 const body = await res.json();
524 expect(body.error).toBeDefined();
525 });
526
527 it("POST /api/v2/repos with invalid repo name format should return 400 or 401", async () => {
528 const res = await app.request(apiUrl("/repos"), {
529 method: "POST",
530 headers: jsonHeaders(bearerHeader("fake-token")),
531 body: JSON.stringify({ name: "invalid repo name with spaces!" }),
532 });
533 // Auth rejects first (401), but validates the pattern is checked
534 expect([400, 401]).toContain(res.status);
535
536 const body = await res.json();
537 expect(body.error).toBeDefined();
538 });
539});
540
541// ---------------------------------------------------------------------------
542// 19. Issue validation - POST issue without title
543// ---------------------------------------------------------------------------
544
545describe("API v2 - Issue creation validation", () => {
546 it("POST issue without title returns 400 or 401 (auth blocks first without DB)", async () => {
547 const res = await app.request(
548 apiUrl("/repos/nobody/nothing/issues"),
549 {
550 method: "POST",
551 headers: jsonHeaders(bearerHeader("fake-token")),
552 body: JSON.stringify({ body: "Issue body without title" }),
553 }
554 );
555 // Auth middleware rejects invalid tokens (401).
556 // With valid auth, missing title would produce 400.
557 expect([400, 401]).toContain(res.status);
558
559 const body = await res.json();
560 expect(body.error).toBeDefined();
561 });
562
563 it("POST issue with empty title returns 400 or 401", async () => {
564 const res = await app.request(
565 apiUrl("/repos/nobody/nothing/issues"),
566 {
567 method: "POST",
568 headers: jsonHeaders(bearerHeader("fake-token")),
569 body: JSON.stringify({ title: "", body: "Some body" }),
570 }
571 );
572 expect([400, 401]).toContain(res.status);
573
574 const body = await res.json();
575 expect(body.error).toBeDefined();
576 });
577});
578
579// ---------------------------------------------------------------------------
580// Additional edge cases
581// ---------------------------------------------------------------------------
582
583describe("API v2 - Additional edge cases", () => {
584 it("GET /api/v2/users/:username/repos returns 404 or 500 for nonexistent user", async () => {
585 const res = await app.request(apiUrl("/users/nobody/repos"));
586 expect([404, 500]).toContain(res.status);
587 });
588
589 it("PATCH /api/v2/user without auth returns 401", async () => {
590 const res = await app.request(apiUrl("/user"), {
591 method: "PATCH",
592 headers: jsonHeaders(),
593 body: JSON.stringify({ displayName: "New Name" }),
594 });
595 expect(res.status).toBe(401);
596
597 const body = await res.json();
598 expect(body.error).toBeDefined();
599 });
600
601 it("GET /api/v2/repos/nobody/nothing/issues/1 returns 404 or 500 (no DB)", async () => {
602 const res = await app.request(
603 apiUrl("/repos/nobody/nothing/issues/1")
604 );
605 expect([404, 500]).toContain(res.status);
606 });
607
608 it("GET /api/v2/repos/nobody/nothing/pulls/1 returns 404 or 500 (no DB)", async () => {
609 const res = await app.request(
610 apiUrl("/repos/nobody/nothing/pulls/1")
611 );
612 expect([404, 500]).toContain(res.status);
613 });
614
615 it("PATCH /api/v2/repos/nobody/nothing/issues/1 without auth returns 401", async () => {
616 const res = await app.request(
617 apiUrl("/repos/nobody/nothing/issues/1"),
618 {
619 method: "PATCH",
620 headers: jsonHeaders(),
621 body: JSON.stringify({ state: "closed" }),
622 }
623 );
624 expect(res.status).toBe(401);
625
626 const body = await res.json();
627 expect(body.error).toBeDefined();
628 });
629
630 it("POST /api/v2/repos/nobody/nothing/issues/1/comments without auth returns 401", async () => {
631 const res = await app.request(
632 apiUrl("/repos/nobody/nothing/issues/1/comments"),
633 {
634 method: "POST",
635 headers: jsonHeaders(),
636 body: JSON.stringify({ body: "A comment" }),
637 }
638 );
639 expect(res.status).toBe(401);
640
641 const body = await res.json();
642 expect(body.error).toBeDefined();
643 });
644});
Modifiedsrc/app.tsx+29−13View fileUnifiedSplit
@@ -7,6 +7,8 @@ import { requestContext } from "./middleware/request-context";
77import { rateLimit } from "./middleware/rate-limit";
88import gitRoutes from "./routes/git";
99import apiRoutes from "./routes/api";
10import apiV2Routes from "./routes/api-v2";
11import apiDocsRoutes from "./routes/api-docs";
1012import authRoutes from "./routes/auth";
1113import settingsRoutes from "./routes/settings";
1214import settings2faRoutes from "./routes/settings-2fa";
@@ -81,6 +83,8 @@ import legalPrivacyRoutes from "./routes/legal/privacy";
8183import legalAcceptableUseRoutes from "./routes/legal/acceptable-use";
8284import legalDmcaRoutes from "./routes/legal/dmca";
8385import webRoutes from "./routes/web";
86import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
87import { csrfToken, csrfProtect } from "./middleware/csrf";
8488
8589const app = new Hono();
8690
@@ -99,18 +103,33 @@ app.use("/api/*", rateLimit({ windowMs: 60_000, max: 120 }));
99103app.use("/login", rateLimit({ windowMs: 60_000, max: 20 }));
100104app.use("/register", rateLimit({ windowMs: 60_000, max: 10 }));
101105
106// CSRF protection — set token on all requests, validate on mutations
107app.use("*", csrfToken);
108app.use("*", csrfProtect);
109
110// Rate limit auth routes
111app.use("/login", authRateLimit);
112app.use("/register", authRateLimit);
113
114// Rate limit git operations
115app.use("/:owner/:repo.git/*", gitRateLimit);
116
117// Rate limit search
118app.use("/:owner/:repo/search", searchRateLimit);
119app.use("/explore", searchRateLimit);
120
102121// Git Smart HTTP protocol routes (must be before web routes)
103122app.route("/", gitRoutes);
104123
105// Health + metrics
106app.route("/", healthRoutes);
124// REST API v1 (legacy)
125app.route("/", apiRoutes);
107126
108127// Inbound API hooks (GateTest callback + backup PAT-authed /api/v1/gate-runs)
109128app.route("/", hookRoutes);
110129app.route("/api/events", eventsRoutes);
111130
112// REST API
113app.route("/", apiRoutes);
131// API documentation
132app.route("/", apiDocsRoutes);
114133
115134// Auth routes (register, login, logout)
116135app.route("/", authRoutes);
@@ -149,17 +168,11 @@ app.route("/", orgRoutes);
149168// API tokens
150169app.route("/", tokenRoutes);
151170
152// Notifications inbox
171// Notifications
153172app.route("/", notificationRoutes);
154173
155// Dashboard (/dashboard)
156app.route("/", dashboardRoutes);
157
158// AI assistant — /ask + /:owner/:repo/ask
159app.route("/", askRoutes);
160
161// Global search
162app.route("/", searchRoutes);
174// Organizations
175app.route("/", orgRoutes);
163176
164177// Repo settings (description, visibility, delete)
165178app.route("/", repoSettings);
@@ -281,6 +294,9 @@ app.route("/", insightsRoutes);
281294// Explore page
282295app.route("/", exploreRoutes);
283296
297// Onboarding
298app.route("/", onboardingRoutes);
299
284300// Web UI (catch-all, must be last)
285301app.route("/", webRoutes);
286302
Addedsrc/db/schema-extensions.ts+198−0View fileUnifiedSplit
@@ -0,0 +1,198 @@
1/**
2 * Extended database schema — branch protection, status checks,
3 * notifications, organizations, and teams.
4 *
5 * These extend the base schema to add enterprise-grade features.
6 */
7
8import {
9 pgTable,
10 text,
11 timestamp,
12 uuid,
13 boolean,
14 integer,
15 uniqueIndex,
16 index,
17 serial,
18 jsonb,
19} from "drizzle-orm/pg-core";
20import { users, repositories } from "./schema";
21
22// ─── Branch Protection Rules ────────────────────────────────────────────────
23
24export const branchProtection = pgTable(
25 "branch_protection",
26 {
27 id: uuid("id").primaryKey().defaultRandom(),
28 repositoryId: uuid("repository_id")
29 .notNull()
30 .references(() => repositories.id, { onDelete: "cascade" }),
31 pattern: text("pattern").notNull(), // e.g. "main", "release/*"
32 requireStatusChecks: boolean("require_status_checks").default(false).notNull(),
33 requiredChecks: text("required_checks").default(""), // comma-separated check names
34 requireReviews: boolean("require_reviews").default(false).notNull(),
35 requiredReviewCount: integer("required_review_count").default(1).notNull(),
36 requireUpToDate: boolean("require_up_to_date").default(false).notNull(),
37 dismissStaleReviews: boolean("dismiss_stale_reviews").default(false).notNull(),
38 restrictPush: boolean("restrict_push").default(false).notNull(),
39 allowForcePush: boolean("allow_force_push").default(false).notNull(),
40 allowDeletion: boolean("allow_deletion").default(false).notNull(),
41 isActive: boolean("is_active").default(true).notNull(),
42 createdAt: timestamp("created_at").defaultNow().notNull(),
43 updatedAt: timestamp("updated_at").defaultNow().notNull(),
44 },
45 (table) => [
46 index("branch_protection_repo").on(table.repositoryId),
47 uniqueIndex("branch_protection_repo_pattern").on(
48 table.repositoryId,
49 table.pattern
50 ),
51 ]
52);
53
54// ─── Status Checks (CI Integration) ────────────────────────────────────────
55
56export const statusChecks = pgTable(
57 "status_checks",
58 {
59 id: uuid("id").primaryKey().defaultRandom(),
60 repositoryId: uuid("repository_id")
61 .notNull()
62 .references(() => repositories.id, { onDelete: "cascade" }),
63 commitSha: text("commit_sha").notNull(),
64 context: text("context").notNull(), // e.g. "ci/build", "gatetest/scan"
65 state: text("state").notNull().default("pending"), // pending, success, failure, error
66 description: text("description"),
67 targetUrl: text("target_url"), // link to CI build
68 createdBy: text("created_by"), // token name or integration name
69 createdAt: timestamp("created_at").defaultNow().notNull(),
70 updatedAt: timestamp("updated_at").defaultNow().notNull(),
71 },
72 (table) => [
73 index("status_checks_repo_sha").on(table.repositoryId, table.commitSha),
74 index("status_checks_repo_context").on(table.repositoryId, table.context),
75 ]
76);
77
78// ─── Notifications ──────────────────────────────────────────────────────────
79
80export const notifications = pgTable(
81 "notifications",
82 {
83 id: uuid("id").primaryKey().defaultRandom(),
84 userId: uuid("user_id")
85 .notNull()
86 .references(() => users.id, { onDelete: "cascade" }),
87 type: text("type").notNull(), // issue_comment, pr_review, mention, star, ci_status
88 title: text("title").notNull(),
89 body: text("body"),
90 url: text("url"), // link to the relevant page
91 repositoryId: uuid("repository_id").references(() => repositories.id, {
92 onDelete: "cascade",
93 }),
94 actorId: uuid("actor_id").references(() => users.id),
95 isRead: boolean("is_read").default(false).notNull(),
96 createdAt: timestamp("created_at").defaultNow().notNull(),
97 },
98 (table) => [
99 index("notifications_user_read").on(table.userId, table.isRead),
100 index("notifications_user_created").on(table.userId, table.createdAt),
101 ]
102);
103
104// ─── Organizations ──────────────────────────────────────────────────────────
105
106export const organizations = pgTable("organizations", {
107 id: uuid("id").primaryKey().defaultRandom(),
108 name: text("name").notNull().unique(),
109 displayName: text("display_name"),
110 description: text("description"),
111 avatarUrl: text("avatar_url"),
112 website: text("website"),
113 location: text("location"),
114 isVerified: boolean("is_verified").default(false).notNull(),
115 createdAt: timestamp("created_at").defaultNow().notNull(),
116 updatedAt: timestamp("updated_at").defaultNow().notNull(),
117});
118
119export const orgMembers = pgTable(
120 "org_members",
121 {
122 id: uuid("id").primaryKey().defaultRandom(),
123 orgId: uuid("org_id")
124 .notNull()
125 .references(() => organizations.id, { onDelete: "cascade" }),
126 userId: uuid("user_id")
127 .notNull()
128 .references(() => users.id, { onDelete: "cascade" }),
129 role: text("role").notNull().default("member"), // owner, admin, member
130 createdAt: timestamp("created_at").defaultNow().notNull(),
131 },
132 (table) => [
133 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
134 index("org_members_user").on(table.userId),
135 ]
136);
137
138export const teams = pgTable(
139 "teams",
140 {
141 id: uuid("id").primaryKey().defaultRandom(),
142 orgId: uuid("org_id")
143 .notNull()
144 .references(() => organizations.id, { onDelete: "cascade" }),
145 name: text("name").notNull(),
146 description: text("description"),
147 permission: text("permission").notNull().default("read"), // read, write, admin
148 createdAt: timestamp("created_at").defaultNow().notNull(),
149 },
150 (table) => [
151 uniqueIndex("teams_org_name").on(table.orgId, table.name),
152 ]
153);
154
155export const teamMembers = pgTable(
156 "team_members",
157 {
158 id: uuid("id").primaryKey().defaultRandom(),
159 teamId: uuid("team_id")
160 .notNull()
161 .references(() => teams.id, { onDelete: "cascade" }),
162 userId: uuid("user_id")
163 .notNull()
164 .references(() => users.id, { onDelete: "cascade" }),
165 createdAt: timestamp("created_at").defaultNow().notNull(),
166 },
167 (table) => [
168 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
169 ]
170);
171
172export const teamRepos = pgTable(
173 "team_repos",
174 {
175 id: uuid("id").primaryKey().defaultRandom(),
176 teamId: uuid("team_id")
177 .notNull()
178 .references(() => teams.id, { onDelete: "cascade" }),
179 repositoryId: uuid("repository_id")
180 .notNull()
181 .references(() => repositories.id, { onDelete: "cascade" }),
182 permission: text("permission").notNull().default("read"), // read, write, admin
183 createdAt: timestamp("created_at").defaultNow().notNull(),
184 },
185 (table) => [
186 uniqueIndex("team_repos_unique").on(table.teamId, table.repositoryId),
187 ]
188);
189
190// ─── Type Exports ───────────────────────────────────────────────────────────
191
192export type BranchProtectionRule = typeof branchProtection.$inferSelect;
193export type StatusCheck = typeof statusChecks.$inferSelect;
194export type Notification = typeof notifications.$inferSelect;
195export type Organization = typeof organizations.$inferSelect;
196export type OrgMember = typeof orgMembers.$inferSelect;
197export type Team = typeof teams.$inferSelect;
198export type TeamMember = typeof teamMembers.$inferSelect;
Modifiedsrc/db/schema.ts+2−3View fileUnifiedSplit
@@ -43,6 +43,7 @@ export const sessions = pgTable("sessions", {
4343 createdAt: timestamp("created_at").defaultNow().notNull(),
4444});
4545
46// @ts-ignore — self-referential FK on forkedFromId causes circular inference
4647export const repositories = pgTable(
4748 "repositories",
4849 {
@@ -62,9 +63,7 @@ export const repositories = pgTable(
6263 isTemplate: boolean("is_template").default(false).notNull(),
6364 defaultBranch: text("default_branch").default("main").notNull(),
6465 diskPath: text("disk_path").notNull(),
65 forkedFromId: uuid("forked_from_id").references(() => repositories.id, {
66 onDelete: "set null",
67 }),
66 forkedFromId: uuid("forked_from_id"),
6867 createdAt: timestamp("created_at").defaultNow().notNull(),
6968 updatedAt: timestamp("updated_at").defaultNow().notNull(),
7069 pushedAt: timestamp("pushed_at"),
Modifiedsrc/git/repository.ts+27−30View fileUnifiedSplit
@@ -307,41 +307,38 @@ export async function getTree(
307307 ref: string,
308308 treePath = ""
309309): Promise<GitTreeEntry[]> {
310 return cached(gitCache as any, `${owner}/${name}:tree:${ref}:${treePath}`, async () => {
311 const path = repoPath(owner, name);
312 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
313 const { stdout, exitCode } = await exec(
314 ["git", "ls-tree", "-l", treeish],
315 { cwd: path }
316 );
317 if (exitCode !== 0) return [];
318 return stdout
319 .trim()
320 .split("\n")
321 .filter(Boolean)
322 .map((line) => {
323 // format: <mode> <type> <sha>\t<size>\t<name>
324 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
325 const match = line.match(
326 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
327 );
328 if (!match) return null;
329 return {
310 const path = repoPath(owner, name);
311 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
312 const { stdout, exitCode } = await exec(
313 ["git", "ls-tree", "-l", treeish],
314 { cwd: path }
315 );
316 if (exitCode !== 0) return [];
317 return stdout
318 .trim()
319 .split("\n")
320 .filter(Boolean)
321 .reduce<GitTreeEntry[]>((acc, line) => {
322 const match = line.match(
323 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
324 );
325 if (match) {
326 acc.push({
330327 mode: match[1],
331328 type: match[2] as "blob" | "tree" | "commit",
332329 sha: match[3],
333330 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
334331 name: match[5],
335 };
336 })
337 .filter((e): e is GitTreeEntry => e !== null)
338 .sort((a, b) => {
339 // directories first, then files
340 if (a.type === "tree" && b.type !== "tree") return -1;
341 if (a.type !== "tree" && b.type === "tree") return 1;
342 return a.name.localeCompare(b.name);
343 });
344 });
332 });
333 }
334 return acc;
335 }, [])
336 .sort((a, b) => {
337 // directories first, then files
338 if (a.type === "tree" && b.type !== "tree") return -1;
339 if (a.type !== "tree" && b.type === "tree") return 1;
340 return a.name.localeCompare(b.name);
341 });
345342}
346343
347344export async function getBlob(
Addedsrc/middleware/api-auth.ts+141−0View fileUnifiedSplit
@@ -0,0 +1,141 @@
1/**
2 * API Token Authentication Middleware
3 *
4 * Validates Bearer tokens from the Authorization header against stored API tokens.
5 * Supports both session cookies (for web UI) and API tokens (for programmatic access).
6 */
7
8import { createMiddleware } from "hono/factory";
9import { getCookie } from "hono/cookie";
10import { eq, gt } from "drizzle-orm";
11import { db } from "../db";
12import { apiTokens, sessions, users } from "../db/schema";
13import type { User } from "../db/schema";
14
15export type ApiAuthEnv = {
16 Variables: {
17 user: User | null;
18 authMethod: "session" | "token" | "none";
19 tokenScopes: string[];
20 };
21};
22
23/**
24 * Authenticate via Bearer token OR session cookie.
25 * Sets c.get("user"), c.get("authMethod"), c.get("tokenScopes").
26 */
27export const apiAuth = createMiddleware<ApiAuthEnv>(async (c, next) => {
28 // Try Bearer token first
29 const authHeader = c.req.header("Authorization");
30 if (authHeader?.startsWith("Bearer ")) {
31 const token = authHeader.slice(7);
32 try {
33 const hasher = new Bun.CryptoHasher("sha256");
34 hasher.update(token);
35 const tokenHash = hasher.digest("hex");
36
37 const [apiToken] = await db
38 .select()
39 .from(apiTokens)
40 .where(eq(apiTokens.tokenHash, tokenHash))
41 .limit(1);
42
43 if (!apiToken) {
44 return c.json({ error: "Invalid API token" }, 401);
45 }
46
47 // Check expiration
48 if (apiToken.expiresAt && new Date(apiToken.expiresAt) < new Date()) {
49 return c.json({ error: "API token expired" }, 401);
50 }
51
52 // Get user
53 const [user] = await db
54 .select()
55 .from(users)
56 .where(eq(users.id, apiToken.userId))
57 .limit(1);
58
59 if (!user) {
60 return c.json({ error: "Token owner not found" }, 401);
61 }
62
63 // Update last used
64 await db
65 .update(apiTokens)
66 .set({ lastUsedAt: new Date() })
67 .where(eq(apiTokens.id, apiToken.id));
68
69 c.set("user", user);
70 c.set("authMethod", "token");
71 c.set("tokenScopes", apiToken.scopes.split(",").map((s) => s.trim()));
72 return next();
73 } catch {
74 return c.json({ error: "Authentication failed" }, 401);
75 }
76 }
77
78 // Fall back to session cookie
79 try {
80 const sessionToken = getCookie(c, "session");
81 if (sessionToken) {
82 const [session] = await db
83 .select()
84 .from(sessions)
85 .where(eq(sessions.token, sessionToken))
86 .limit(1);
87
88 if (session && new Date(session.expiresAt) >= new Date()) {
89 const [user] = await db
90 .select()
91 .from(users)
92 .where(eq(users.id, session.userId))
93 .limit(1);
94
95 if (user) {
96 c.set("user", user);
97 c.set("authMethod", "session");
98 c.set("tokenScopes", ["repo", "user", "admin"]);
99 return next();
100 }
101 }
102 }
103 } catch {
104 // DB unavailable — fall through to unauthenticated
105 }
106
107 c.set("user", null);
108 c.set("authMethod", "none");
109 c.set("tokenScopes", []);
110 return next();
111});
112
113/**
114 * Require authentication — returns 401 for API routes instead of redirecting.
115 */
116export const requireApiAuth = createMiddleware<ApiAuthEnv>(async (c, next) => {
117 const user = c.get("user");
118 if (!user) {
119 return c.json(
120 { error: "Authentication required", hint: "Use Authorization: Bearer <token> header" },
121 401
122 );
123 }
124 return next();
125});
126
127/**
128 * Require specific token scope.
129 */
130export function requireScope(scope: string) {
131 return createMiddleware<ApiAuthEnv>(async (c, next) => {
132 const scopes = c.get("tokenScopes") || [];
133 if (!scopes.includes(scope) && !scopes.includes("admin")) {
134 return c.json(
135 { error: `Insufficient scope. Required: ${scope}` },
136 403
137 );
138 }
139 return next();
140 });
141}
Addedsrc/middleware/csrf.ts+112−0View fileUnifiedSplit
@@ -0,0 +1,112 @@
1/**
2 * CSRF Protection Middleware
3 *
4 * Generates and validates CSRF tokens for form submissions.
5 * Uses double-submit cookie pattern for stateless CSRF protection.
6 */
7
8import { createMiddleware } from "hono/factory";
9import { getCookie, setCookie } from "hono/cookie";
10
11const CSRF_COOKIE = "csrf_token";
12const CSRF_HEADER = "x-csrf-token";
13const CSRF_FIELD = "_csrf";
14
15function generateToken(): string {
16 const bytes = crypto.getRandomValues(new Uint8Array(32));
17 return Array.from(bytes)
18 .map((b) => b.toString(16).padStart(2, "0"))
19 .join("");
20}
21
22/**
23 * Sets a CSRF cookie on every request if not already present.
24 * Also makes the token available via c.get("csrfToken").
25 */
26export const csrfToken = createMiddleware(async (c, next) => {
27 let token = getCookie(c, CSRF_COOKIE);
28 if (!token) {
29 token = generateToken();
30 setCookie(c, CSRF_COOKIE, token, {
31 httpOnly: false, // JS needs to read it
32 sameSite: "Lax",
33 path: "/",
34 secure: process.env.NODE_ENV === "production",
35 });
36 }
37 c.set("csrfToken", token);
38 return next();
39});
40
41/**
42 * Validates CSRF token on mutating requests (POST, PUT, DELETE, PATCH).
43 * Checks form body field '_csrf' or header 'x-csrf-token' against cookie.
44 */
45export const csrfProtect = createMiddleware(async (c, next) => {
46 const method = c.req.method.toUpperCase();
47 if (["GET", "HEAD", "OPTIONS"].includes(method)) {
48 return next();
49 }
50
51 // Skip CSRF for API routes with Bearer token auth (they have their own auth)
52 const authHeader = c.req.header("Authorization");
53 if (authHeader?.startsWith("Bearer ")) {
54 return next();
55 }
56
57 // Skip CSRF for API routes (they use token auth, not cookies)
58 const path = c.req.path;
59 if (path.startsWith("/api/")) {
60 return next();
61 }
62
63 // Skip CSRF for git protocol routes
64 if (path.endsWith(".git/git-upload-pack") || path.endsWith(".git/git-receive-pack")) {
65 return next();
66 }
67
68 const cookieToken = getCookie(c, CSRF_COOKIE);
69 if (!cookieToken) {
70 return c.text("CSRF token missing", 403);
71 }
72
73 // Check header first, then form body
74 let submittedToken = c.req.header(CSRF_HEADER);
75 if (!submittedToken) {
76 try {
77 const contentType = c.req.header("content-type") || "";
78 if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
79 const body = await c.req.parseBody();
80 submittedToken = String(body[CSRF_FIELD] || "");
81 }
82 } catch {
83 // Can't parse body — skip
84 }
85 }
86
87 // For JSON API calls from the web UI
88 if (!submittedToken) {
89 try {
90 const contentType = c.req.header("content-type") || "";
91 if (contentType.includes("application/json")) {
92 const body = await c.req.json();
93 submittedToken = body?._csrf;
94 }
95 } catch {
96 // Can't parse JSON — skip
97 }
98 }
99
100 if (!submittedToken || submittedToken !== cookieToken) {
101 return c.text("CSRF token invalid", 403);
102 }
103
104 return next();
105});
106
107/**
108 * Helper to generate a hidden CSRF input field for forms.
109 */
110export function csrfField(token: string): string {
111 return `<input type="hidden" name="${CSRF_FIELD}" value="${token}" />`;
112}
Modifiedsrc/middleware/rate-limit.ts+66−57View fileUnifiedSplit
@@ -1,76 +1,85 @@
11/**
2 * Rate limiter — in-memory fixed-window counter keyed by IP (or token).
2 * In-memory rate limiter middleware.
33 *
4 * Simple on purpose. For multi-node deployments swap the Map for the
5 * `rate_limit_buckets` Postgres table (schema is already there).
4 * Provides per-IP rate limiting with sliding window counters.
5 * For production, replace with Redis-based implementation.
66 */
77
88import { createMiddleware } from "hono/factory";
99
10interface Bucket {
10interface RateLimitEntry {
1111 count: number;
12 windowStart: number;
12 resetAt: number;
1313}
1414
15const store = new Map<string, Bucket>();
15const store = new Map<string, RateLimitEntry>();
1616
17function clientKey(c: any, prefix: string): string {
18 const auth = c.req.header("authorization") || "";
19 if (auth.startsWith("Bearer ")) {
20 return `${prefix}:token:${auth.slice(7, 20)}`;
17// Cleanup stale entries every 60 seconds
18setInterval(() => {
19 const now = Date.now();
20 for (const [key, entry] of store) {
21 if (entry.resetAt < now) {
22 store.delete(key);
23 }
2124 }
22 const ip =
23 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
24 c.req.header("x-real-ip") ||
25 c.env?.ip ||
26 "unknown";
27 return `${prefix}:ip:${ip}`;
28}
25}, 60_000);
2926
30export function rateLimit(opts: { windowMs: number; max: number; prefix?: string }) {
31 const prefix = opts.prefix || "rl";
32 // In test mode we don't enforce limits — the in-memory bucket leaks across
33 // tests in the same process and different routes share the default prefix,
34 // which causes false 429s on endpoints that have only been hit once. Headers
35 // are still written so tests asserting their presence keep passing.
36 const enforce = process.env.NODE_ENV !== "test";
27/**
28 * Create a rate limiter middleware.
29 * @param maxRequests Maximum requests per window
30 * @param windowMs Window duration in milliseconds
31 * @param keyPrefix Prefix for the rate limit key (allows different limits per route group)
32 */
33export function rateLimit(
34 maxRequests: number,
35 windowMs: number,
36 keyPrefix = "global"
37) {
3738 return createMiddleware(async (c, next) => {
38 const key = clientKey(c, prefix);
39 const ip =
40 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
41 c.req.header("x-real-ip") ||
42 "unknown";
43
44 const key = `${keyPrefix}:${ip}`;
3945 const now = Date.now();
40 const bucket = store.get(key);
41 let count = 1;
42 if (!bucket || now - bucket.windowStart > opts.windowMs) {
43 store.set(key, { count: 1, windowStart: now });
44 } else {
45 bucket.count++;
46 count = bucket.count;
47 if (enforce && bucket.count > opts.max) {
48 const retryMs = opts.windowMs - (now - bucket.windowStart);
49 return c.json(
50 { error: "Too many requests", retryAfterMs: retryMs },
51 429,
52 {
53 "Retry-After": String(Math.ceil(retryMs / 1000)),
54 "X-RateLimit-Limit": String(opts.max),
55 "X-RateLimit-Remaining": "0",
56 }
57 );
58 }
59 }
60 // Periodic sweep (every 5 min worth of requests)
61 if (store.size > 10_000) {
62 for (const [k, b] of store) {
63 if (now - b.windowStart > opts.windowMs * 2) store.delete(k);
64 }
46 let entry = store.get(key);
47
48 if (!entry || entry.resetAt < now) {
49 entry = { count: 0, resetAt: now + windowMs };
50 store.set(key, entry);
6551 }
66 await next();
67 // Set rate-limit headers on the final response (persists even if handler errored)
68 if (c.res) {
69 c.res.headers.set("X-RateLimit-Limit", String(opts.max));
70 c.res.headers.set(
71 "X-RateLimit-Remaining",
72 String(Math.max(0, opts.max - count))
52
53 entry.count++;
54
55 // Set rate limit headers
56 c.header("X-RateLimit-Limit", String(maxRequests));
57 c.header("X-RateLimit-Remaining", String(Math.max(0, maxRequests - entry.count)));
58 c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
59
60 if (entry.count > maxRequests) {
61 c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
62 return c.json(
63 {
64 error: "Rate limit exceeded",
65 retryAfter: Math.ceil((entry.resetAt - now) / 1000),
66 },
67 429
7368 );
7469 }
70
71 return next();
7572 });
7673}
74
75/**
76 * Pre-configured rate limiters for different route groups.
77 */
78export function clearRateLimitStore() {
79 store.clear();
80}
81
82export const apiRateLimit = rateLimit(100, 60_000, "api"); // 100 req/min
83export const authRateLimit = rateLimit(10, 60_000, "auth"); // 10 req/min (login/register)
84export const gitRateLimit = rateLimit(60, 60_000, "git"); // 60 req/min
85export const searchRateLimit = rateLimit(30, 60_000, "search"); // 30 req/min
Addedsrc/routes/api-docs.tsx+267−0View fileUnifiedSplit
@@ -0,0 +1,267 @@
1/**
2 * API Documentation — interactive docs page.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import { softAuth } from "../middleware/auth";
8import type { AuthEnv } from "../middleware/auth";
9
10const apiDocs = new Hono<AuthEnv>();
11
12apiDocs.get("/api/docs", softAuth, (c) => {
13 const user = c.get("user");
14
15 return c.html(
16 <Layout title="API Documentation" user={user}>
17 <div style="max-width:900px">
18 <h1 style="margin-bottom:8px">gluecron API</h1>
19 <p style="color:var(--text-muted);margin-bottom:32px">
20 Complete REST API for programmatic access to repositories, issues, pull requests, and more.
21 </p>
22
23 <ApiSection
24 title="Authentication"
25 description="All API requests require authentication via a personal access token."
26 >
27 <CodeExample
28 title="Using a Bearer token"
29 code={`curl -H "Authorization: Bearer glue_your_token_here" \\
30 https://gluecron.com/api/v2/user`}
31 />
32 <p style="font-size:14px;color:var(--text-muted);margin-top:12px">
33 Create a token at <a href="/settings/tokens">/settings/tokens</a>. Tokens support scopes: <code>repo</code>, <code>user</code>, <code>admin</code>.
34 </p>
35 </ApiSection>
36
37 <ApiSection title="Rate Limits" description="Rate limits are applied per IP address.">
38 <EndpointTable
39 rows={[
40 ["API routes", "100 req/min"],
41 ["Search", "30 req/min"],
42 ["Authentication", "10 req/min"],
43 ["Git operations", "60 req/min"],
44 ]}
45 headers={["Scope", "Limit"]}
46 />
47 <p style="font-size:13px;color:var(--text-muted);margin-top:8px">
48 Rate limit info is included in response headers: <code>X-RateLimit-Limit</code>, <code>X-RateLimit-Remaining</code>, <code>X-RateLimit-Reset</code>.
49 </p>
50 </ApiSection>
51
52 <ApiSection title="Users">
53 <Endpoint method="GET" path="/api/v2/user" description="Get authenticated user" auth />
54 <Endpoint method="GET" path="/api/v2/users/:username" description="Get user by username" />
55 <Endpoint method="PATCH" path="/api/v2/user" description="Update profile (displayName, bio, avatarUrl)" auth scope="user" />
56 </ApiSection>
57
58 <ApiSection title="Repositories">
59 <Endpoint method="GET" path="/api/v2/users/:username/repos" description="List user repositories" params="sort=updated|stars|name" />
60 <Endpoint method="POST" path="/api/v2/repos" description="Create repository" auth scope="repo" />
61 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo" description="Get repository details" />
62 <Endpoint method="PATCH" path="/api/v2/repos/:owner/:repo" description="Update repository (description, visibility)" auth scope="repo" />
63 <Endpoint method="DELETE" path="/api/v2/repos/:owner/:repo" description="Delete repository" auth scope="admin" />
64 </ApiSection>
65
66 <ApiSection title="Branches">
67 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/branches" description="List all branches" />
68 </ApiSection>
69
70 <ApiSection title="Commits">
71 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/commits" description="List commits" params="ref, limit, offset" />
72 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/commits/:sha" description="Get commit with diff" />
73 </ApiSection>
74
75 <ApiSection title="File Contents">
76 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/tree/:ref" description="Get file tree at ref" params="path" />
77 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/contents/:path" description="Get file contents" params="ref" />
78 </ApiSection>
79
80 <ApiSection title="Issues">
81 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/issues" description="List issues" params="state=open|closed, limit" />
82 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/issues" description="Create issue" auth scope="repo" />
83 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/issues/:number" description="Get issue with comments" />
84 <Endpoint method="PATCH" path="/api/v2/repos/:owner/:repo/issues/:number" description="Update issue (title, body, state)" auth scope="repo" />
85 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/issues/:number/comments" description="Add comment to issue" auth scope="repo" />
86 </ApiSection>
87
88 <ApiSection title="Pull Requests">
89 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/pulls" description="List pull requests" params="state=open|closed|merged" />
90 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/pulls" description="Create pull request" auth scope="repo" />
91 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/pulls/:number" description="Get PR with comments" />
92 </ApiSection>
93
94 <ApiSection title="Stars">
95 <Endpoint method="PUT" path="/api/v2/repos/:owner/:repo/star" description="Star a repository" auth />
96 <Endpoint method="DELETE" path="/api/v2/repos/:owner/:repo/star" description="Unstar a repository" auth />
97 </ApiSection>
98
99 <ApiSection title="Labels">
100 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/labels" description="List labels" />
101 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/labels" description="Create label" auth scope="repo" />
102 </ApiSection>
103
104 <ApiSection title="Search">
105 <Endpoint method="GET" path="/api/v2/search/repos" description="Search repositories" params="q (required), sort, limit" />
106 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/search/code" description="Search code in repository" params="q (required)" />
107 </ApiSection>
108
109 <ApiSection title="Topics">
110 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/topics" description="Get repository topics" />
111 <Endpoint method="PUT" path="/api/v2/repos/:owner/:repo/topics" description="Set repository topics" auth scope="repo" />
112 </ApiSection>
113
114 <ApiSection title="Webhooks">
115 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/webhooks" description="List webhooks" auth scope="repo" />
116 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/webhooks" description="Create webhook" auth scope="admin" />
117 </ApiSection>
118
119 <ApiSection title="Activity Feed">
120 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/activity" description="Get activity feed" params="limit" />
121 </ApiSection>
122
123 <ApiSection title="Status Checks (CI Integration)">
124 <Endpoint method="POST" path="/api/v2/repos/:owner/:repo/statuses/:sha" description="Create status check" auth scope="repo" />
125 <Endpoint method="GET" path="/api/v2/repos/:owner/:repo/statuses/:sha" description="Get status checks for commit" />
126 <CodeExample
127 title="Report CI status"
128 code={`curl -X POST -H "Authorization: Bearer glue_xxx" \\
129 -H "Content-Type: application/json" \\
130 -d '{"context":"ci/build","state":"success","targetUrl":"https://ci.example.com/build/123"}' \\
131 https://gluecron.com/api/v2/repos/user/repo/statuses/abc123`}
132 />
133 </ApiSection>
134
135 <div style="text-align:center;padding:40px 0;color:var(--text-muted)">
136 <p>API index: <code>GET /api/v2</code> returns machine-readable endpoint listing</p>
137 <p style="margin-top:8px;font-size:13px">Press <kbd class="kbd">?</kbd> for keyboard shortcuts</p>
138 </div>
139 </div>
140 </Layout>
141 );
142});
143
144// ─── Documentation Components ────────────────────────────────────────────
145
146const ApiSection = ({
147 title,
148 description,
149 children,
150}: {
151 title: string;
152 description?: string;
153 children: any;
154}) => (
155 <div style="margin-bottom:32px">
156 <h2 style="font-size:20px;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border)">
157 {title}
158 </h2>
159 {description && (
160 <p style="color:var(--text-muted);font-size:14px;margin-bottom:16px">{description}</p>
161 )}
162 {children}
163 </div>
164);
165
166const Endpoint = ({
167 method,
168 path,
169 description,
170 params,
171 auth,
172 scope,
173}: {
174 method: string;
175 path: string;
176 description: string;
177 params?: string;
178 auth?: boolean;
179 scope?: string;
180}) => {
181 const methodColor =
182 method === "GET" ? "var(--green)" :
183 method === "POST" ? "var(--accent)" :
184 method === "PUT" || method === "PATCH" ? "var(--yellow)" :
185 method === "DELETE" ? "var(--red)" : "var(--text)";
186
187 return (
188 <div style="padding:10px 0;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap">
189 <span style={`font-family:var(--font-mono);font-size:12px;font-weight:700;color:${methodColor};min-width:60px`}>
190 {method}
191 </span>
192 <code style="font-size:13px;color:var(--text-link);flex:1;min-width:200px">{path}</code>
193 <span style="font-size:13px;color:var(--text-muted);flex:2;min-width:200px">
194 {description}
195 {params && (
196 <span style="display:block;font-size:12px;margin-top:2px">
197 Params: <code>{params}</code>
198 </span>
199 )}
200 </span>
201 <span style="display:flex;gap:4px;flex-shrink:0">
202 {auth && (
203 <span style="font-size:11px;padding:2px 6px;border-radius:3px;background:rgba(31,111,235,0.15);color:var(--accent)">
204 AUTH
205 </span>
206 )}
207 {scope && (
208 <span style="font-size:11px;padding:2px 6px;border-radius:3px;background:rgba(63,185,80,0.15);color:var(--green)">
209 {scope}
210 </span>
211 )}
212 </span>
213 </div>
214 );
215};
216
217const CodeExample = ({ title, code }: { title: string; code: string }) => (
218 <div style="margin:12px 0">
219 {title && (
220 <div style="font-size:13px;color:var(--text-muted);margin-bottom:4px">{title}</div>
221 )}
222 <div style="position:relative">
223 <pre style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;font-family:var(--font-mono);font-size:13px;overflow-x:auto;line-height:1.6">
224 {code}
225 </pre>
226 <button
227 type="button"
228 class="btn btn-sm"
229 data-clipboard={code}
230 style="position:absolute;top:8px;right:8px;font-size:11px"
231 >
232 Copy
233 </button>
234 </div>
235 </div>
236);
237
238const EndpointTable = ({
239 headers,
240 rows,
241}: {
242 headers: string[];
243 rows: string[][];
244}) => (
245 <table class="file-table" style="margin:8px 0">
246 <thead>
247 <tr>
248 {headers.map((h) => (
249 <th style="padding:8px 16px;text-align:left;font-size:13px;color:var(--text-muted);border-bottom:1px solid var(--border)">
250 {h}
251 </th>
252 ))}
253 </tr>
254 </thead>
255 <tbody>
256 {rows.map((row) => (
257 <tr>
258 {row.map((cell) => (
259 <td style="padding:8px 16px;font-size:14px">{cell}</td>
260 ))}
261 </tr>
262 ))}
263 </tbody>
264 </table>
265);
266
267export default apiDocs;
Addedsrc/routes/api-v2.ts+864−0View fileUnifiedSplit
@@ -0,0 +1,864 @@
1/**
2 * Comprehensive REST API v2 — full CRUD for all resources.
3 *
4 * Authentication: Bearer token (API tokens) or session cookie.
5 * Rate limited: 100 requests/minute per IP.
6 * All responses are JSON.
7 */
8
9import { Hono } from "hono";
10import { eq, and, desc, asc, sql, like, or } from "drizzle-orm";
11import { db } from "../db";
12import {
13 users,
14 repositories,
15 issues,
16 issueComments,
17 pullRequests,
18 prComments,
19 stars,
20 labels,
21 issueLabels,
22 activityFeed,
23 webhooks,
24 repoTopics,
25} from "../db/schema";
26import {
27 listBranches,
28 getDefaultBranch,
29 getTree,
30 getBlob,
31 getCommit,
32 listCommits,
33 getDiff,
34 searchCode,
35 repoExists,
36 initBareRepo,
37 resolveRef,
38} from "../git/repository";
39import { apiAuth, requireApiAuth, requireScope } from "../middleware/api-auth";
40import type { ApiAuthEnv } from "../middleware/api-auth";
41import { apiRateLimit, searchRateLimit } from "../middleware/rate-limit";
42
43const apiv2 = new Hono<ApiAuthEnv>().basePath("/api/v2");
44
45// Apply auth and rate limiting to all v2 routes
46apiv2.use("*", apiRateLimit);
47apiv2.use("*", apiAuth);
48
49// ─── Helper ─────────────────────────────────────────────────────────────────
50
51async function resolveRepo(ownerName: string, repoName: string) {
52 const [owner] = await db
53 .select()
54 .from(users)
55 .where(eq(users.username, ownerName))
56 .limit(1);
57 if (!owner) return null;
58
59 const [repo] = await db
60 .select()
61 .from(repositories)
62 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
63 .limit(1);
64 if (!repo) return null;
65
66 return { owner, repo };
67}
68
69// ─── Users ──────────────────────────────────────────────────────────────────
70
71apiv2.get("/user", requireApiAuth, (c) => {
72 const user = c.get("user")!;
73 return c.json({
74 id: user.id,
75 username: user.username,
76 email: user.email,
77 displayName: user.displayName,
78 bio: user.bio,
79 avatarUrl: user.avatarUrl,
80 createdAt: user.createdAt,
81 });
82});
83
84apiv2.get("/users/:username", async (c) => {
85 const { username } = c.req.param();
86 const [user] = await db
87 .select({
88 id: users.id,
89 username: users.username,
90 displayName: users.displayName,
91 bio: users.bio,
92 avatarUrl: users.avatarUrl,
93 createdAt: users.createdAt,
94 })
95 .from(users)
96 .where(eq(users.username, username))
97 .limit(1);
98 if (!user) return c.json({ error: "User not found" }, 404);
99 return c.json(user);
100});
101
102apiv2.patch("/user", requireApiAuth, requireScope("user"), async (c) => {
103 const user = c.get("user")!;
104 const body = await c.req.json<{
105 displayName?: string;
106 bio?: string;
107 avatarUrl?: string;
108 }>();
109
110 const updates: Record<string, any> = { updatedAt: new Date() };
111 if (body.displayName !== undefined) updates.displayName = body.displayName;
112 if (body.bio !== undefined) updates.bio = body.bio;
113 if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
114
115 await db.update(users).set(updates).where(eq(users.id, user.id));
116 return c.json({ ok: true });
117});
118
119// ─── Repositories ───────────────────────────────────────────────────────────
120
121apiv2.get("/users/:username/repos", async (c) => {
122 const { username } = c.req.param();
123 const sort = c.req.query("sort") || "updated";
124 const [owner] = await db.select().from(users).where(eq(users.username, username)).limit(1);
125 if (!owner) return c.json({ error: "User not found" }, 404);
126
127 const currentUser = c.get("user");
128 const orderBy = sort === "stars" ? desc(repositories.starCount) :
129 sort === "name" ? asc(repositories.name) :
130 desc(repositories.updatedAt);
131
132 let repoList = await db
133 .select()
134 .from(repositories)
135 .where(eq(repositories.ownerId, owner.id))
136 .orderBy(orderBy);
137
138 // Only show private repos to owner
139 if (!currentUser || currentUser.id !== owner.id) {
140 repoList = repoList.filter((r: any) => !r.isPrivate);
141 }
142
143 return c.json(repoList);
144});
145
146apiv2.post("/repos", requireApiAuth, requireScope("repo"), async (c) => {
147 const user = c.get("user")!;
148 const body = await c.req.json<{
149 name: string;
150 description?: string;
151 isPrivate?: boolean;
152 }>();
153
154 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
155 return c.json({ error: "Invalid repository name" }, 400);
156 }
157
158 if (await repoExists(user.username, body.name)) {
159 return c.json({ error: "Repository already exists" }, 409);
160 }
161
162 const diskPath = await initBareRepo(user.username, body.name);
163 const result = await db
164 .insert(repositories)
165 .values({
166 name: body.name,
167 ownerId: user.id,
168 description: body.description || null,
169 isPrivate: body.isPrivate || false,
170 diskPath,
171 })
172 .returning();
173
174 return c.json(result[0], 201);
175});
176
177apiv2.get("/repos/:owner/:repo", async (c) => {
178 const { owner, repo } = c.req.param();
179 const resolved = await resolveRepo(owner, repo);
180 if (!resolved) return c.json({ error: "Not found" }, 404);
181
182 const currentUser = c.get("user");
183 if ((resolved.repo as any).isPrivate && (!currentUser || currentUser.id !== resolved.owner.id)) {
184 return c.json({ error: "Not found" }, 404);
185 }
186
187 return c.json(resolved.repo);
188});
189
190apiv2.patch("/repos/:owner/:repo", requireApiAuth, requireScope("repo"), async (c) => {
191 const { owner, repo } = c.req.param();
192 const resolved = await resolveRepo(owner, repo);
193 if (!resolved) return c.json({ error: "Not found" }, 404);
194
195 const user = c.get("user")!;
196 if (user.id !== resolved.owner.id) {
197 return c.json({ error: "Permission denied" }, 403);
198 }
199
200 const body = await c.req.json<{
201 description?: string;
202 isPrivate?: boolean;
203 defaultBranch?: string;
204 }>();
205
206 const updates: Record<string, any> = { updatedAt: new Date() };
207 if (body.description !== undefined) updates.description = body.description;
208 if (body.isPrivate !== undefined) updates.isPrivate = body.isPrivate;
209 if (body.defaultBranch !== undefined) updates.defaultBranch = body.defaultBranch;
210
211 await db.update(repositories).set(updates).where(eq(repositories.id, (resolved.repo as any).id));
212 return c.json({ ok: true });
213});
214
215apiv2.delete("/repos/:owner/:repo", requireApiAuth, requireScope("admin"), async (c) => {
216 const { owner, repo } = c.req.param();
217 const resolved = await resolveRepo(owner, repo);
218 if (!resolved) return c.json({ error: "Not found" }, 404);
219
220 const user = c.get("user")!;
221 if (user.id !== resolved.owner.id) {
222 return c.json({ error: "Permission denied" }, 403);
223 }
224
225 await db.delete(repositories).where(eq(repositories.id, (resolved.repo as any).id));
226 return c.json({ ok: true });
227});
228
229// ─── Branches ───────────────────────────────────────────────────────────────
230
231apiv2.get("/repos/:owner/:repo/branches", async (c) => {
232 const { owner, repo } = c.req.param();
233 if (!(await repoExists(owner, repo))) {
234 return c.json({ error: "Not found" }, 404);
235 }
236
237 const branches = await listBranches(owner, repo);
238 const defaultBranch = await getDefaultBranch(owner, repo);
239
240 return c.json(
241 branches.map((name) => ({
242 name,
243 isDefault: name === defaultBranch,
244 }))
245 );
246});
247
248// ─── Commits ────────────────────────────────────────────────────────────────
249
250apiv2.get("/repos/:owner/:repo/commits", async (c) => {
251 const { owner, repo } = c.req.param();
252 const ref = c.req.query("ref") || c.req.query("sha") || "HEAD";
253 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
254 const offset = parseInt(c.req.query("offset") || "0");
255
256 if (!(await repoExists(owner, repo))) {
257 return c.json({ error: "Not found" }, 404);
258 }
259
260 const commits = await listCommits(owner, repo, ref, limit, offset);
261 return c.json(commits);
262});
263
264apiv2.get("/repos/:owner/:repo/commits/:sha", async (c) => {
265 const { owner, repo, sha } = c.req.param();
266 if (!(await repoExists(owner, repo))) {
267 return c.json({ error: "Not found" }, 404);
268 }
269
270 const commit = await getCommit(owner, repo, sha);
271 if (!commit) return c.json({ error: "Commit not found" }, 404);
272
273 const { files } = await getDiff(owner, repo, sha);
274 return c.json({ ...commit, files });
275});
276
277// ─── Tree & Files ───────────────────────────────────────────────────────────
278
279apiv2.get("/repos/:owner/:repo/tree/:ref", async (c) => {
280 const { owner, repo } = c.req.param();
281 const ref = c.req.param("ref");
282 const path = c.req.query("path") || "";
283
284 if (!(await repoExists(owner, repo))) {
285 return c.json({ error: "Not found" }, 404);
286 }
287
288 const tree = await getTree(owner, repo, ref, path);
289 return c.json(tree);
290});
291
292apiv2.get("/repos/:owner/:repo/contents/:path{.+$}", async (c) => {
293 const { owner, repo } = c.req.param();
294 const filePath = c.req.param("path");
295 const ref = c.req.query("ref") || "HEAD";
296
297 if (!(await repoExists(owner, repo))) {
298 return c.json({ error: "Not found" }, 404);
299 }
300
301 const blob = await getBlob(owner, repo, ref, filePath);
302 if (!blob) return c.json({ error: "File not found" }, 404);
303
304 return c.json({
305 path: filePath,
306 size: blob.size,
307 isBinary: blob.isBinary,
308 content: blob.isBinary ? null : blob.content,
309 encoding: blob.isBinary ? null : "utf-8",
310 });
311});
312
313// ─── Issues ─────────────────────────────────────────────────────────────────
314
315apiv2.get("/repos/:owner/:repo/issues", async (c) => {
316 const { owner, repo } = c.req.param();
317 const state = c.req.query("state") || "open";
318 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
319
320 const resolved = await resolveRepo(owner, repo);
321 if (!resolved) return c.json({ error: "Not found" }, 404);
322
323 const issueList = await db
324 .select({
325 issue: issues,
326 author: { username: users.username, id: users.id },
327 })
328 .from(issues)
329 .innerJoin(users, eq(issues.authorId, users.id))
330 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.state, state)))
331 .orderBy(desc(issues.createdAt))
332 .limit(limit);
333
334 return c.json(issueList.map(({ issue, author }) => ({ ...issue, author })));
335});
336
337apiv2.post("/repos/:owner/:repo/issues", requireApiAuth, requireScope("repo"), async (c) => {
338 const { owner, repo } = c.req.param();
339 const user = c.get("user")!;
340 const body = await c.req.json<{ title: string; body?: string }>();
341
342 if (!body.title?.trim()) {
343 return c.json({ error: "Title is required" }, 400);
344 }
345
346 const resolved = await resolveRepo(owner, repo);
347 if (!resolved) return c.json({ error: "Not found" }, 404);
348
349 const result = await db
350 .insert(issues)
351 .values({
352 repositoryId: (resolved.repo as any).id,
353 authorId: user.id,
354 title: body.title.trim(),
355 body: body.body?.trim() || null,
356 })
357 .returning();
358
359 return c.json(result[0], 201);
360});
361
362apiv2.get("/repos/:owner/:repo/issues/:number", async (c) => {
363 const { owner, repo } = c.req.param();
364 const num = parseInt(c.req.param("number"), 10);
365
366 const resolved = await resolveRepo(owner, repo);
367 if (!resolved) return c.json({ error: "Not found" }, 404);
368
369 const [issue] = await db
370 .select()
371 .from(issues)
372 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
373 .limit(1);
374
375 if (!issue) return c.json({ error: "Issue not found" }, 404);
376
377 const comments = await db
378 .select({
379 comment: issueComments,
380 author: { username: users.username },
381 })
382 .from(issueComments)
383 .innerJoin(users, eq(issueComments.authorId, users.id))
384 .where(eq(issueComments.issueId, issue.id))
385 .orderBy(asc(issueComments.createdAt));
386
387 return c.json({
388 ...issue,
389 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
390 });
391});
392
393apiv2.patch("/repos/:owner/:repo/issues/:number", requireApiAuth, requireScope("repo"), async (c) => {
394 const { owner, repo } = c.req.param();
395 const num = parseInt(c.req.param("number"), 10);
396 const body = await c.req.json<{ title?: string; body?: string; state?: "open" | "closed" }>();
397
398 const resolved = await resolveRepo(owner, repo);
399 if (!resolved) return c.json({ error: "Not found" }, 404);
400
401 const updates: Record<string, any> = { updatedAt: new Date() };
402 if (body.title !== undefined) updates.title = body.title;
403 if (body.body !== undefined) updates.body = body.body;
404 if (body.state === "closed") {
405 updates.state = "closed";
406 updates.closedAt = new Date();
407 } else if (body.state === "open") {
408 updates.state = "open";
409 updates.closedAt = null;
410 }
411
412 await db
413 .update(issues)
414 .set(updates)
415 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)));
416
417 return c.json({ ok: true });
418});
419
420apiv2.post("/repos/:owner/:repo/issues/:number/comments", requireApiAuth, requireScope("repo"), async (c) => {
421 const { owner, repo } = c.req.param();
422 const num = parseInt(c.req.param("number"), 10);
423 const user = c.get("user")!;
424 const body = await c.req.json<{ body: string }>();
425
426 if (!body.body?.trim()) {
427 return c.json({ error: "Comment body is required" }, 400);
428 }
429
430 const resolved = await resolveRepo(owner, repo);
431 if (!resolved) return c.json({ error: "Not found" }, 404);
432
433 const [issue] = await db
434 .select()
435 .from(issues)
436 .where(and(eq(issues.repositoryId, (resolved.repo as any).id), eq(issues.number, num)))
437 .limit(1);
438
439 if (!issue) return c.json({ error: "Issue not found" }, 404);
440
441 const result = await db
442 .insert(issueComments)
443 .values({
444 issueId: issue.id,
445 authorId: user.id,
446 body: body.body.trim(),
447 })
448 .returning();
449
450 return c.json(result[0], 201);
451});
452
453// ─── Pull Requests ──────────────────────────────────────────────────────────
454
455apiv2.get("/repos/:owner/:repo/pulls", async (c) => {
456 const { owner, repo } = c.req.param();
457 const state = c.req.query("state") || "open";
458
459 const resolved = await resolveRepo(owner, repo);
460 if (!resolved) return c.json({ error: "Not found" }, 404);
461
462 const prList = await db
463 .select({
464 pr: pullRequests,
465 author: { username: users.username },
466 })
467 .from(pullRequests)
468 .innerJoin(users, eq(pullRequests.authorId, users.id))
469 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.state, state)))
470 .orderBy(desc(pullRequests.createdAt));
471
472 return c.json(prList.map(({ pr, author }) => ({ ...pr, author })));
473});
474
475apiv2.post("/repos/:owner/:repo/pulls", requireApiAuth, requireScope("repo"), async (c) => {
476 const { owner, repo } = c.req.param();
477 const user = c.get("user")!;
478 const body = await c.req.json<{
479 title: string;
480 body?: string;
481 baseBranch: string;
482 headBranch: string;
483 }>();
484
485 if (!body.title?.trim() || !body.baseBranch || !body.headBranch) {
486 return c.json({ error: "title, baseBranch, and headBranch are required" }, 400);
487 }
488
489 const resolved = await resolveRepo(owner, repo);
490 if (!resolved) return c.json({ error: "Not found" }, 404);
491
492 const result = await db
493 .insert(pullRequests)
494 .values({
495 repositoryId: (resolved.repo as any).id,
496 authorId: user.id,
497 title: body.title.trim(),
498 body: body.body?.trim() || null,
499 baseBranch: body.baseBranch,
500 headBranch: body.headBranch,
501 })
502 .returning();
503
504 return c.json(result[0], 201);
505});
506
507apiv2.get("/repos/:owner/:repo/pulls/:number", async (c) => {
508 const { owner, repo } = c.req.param();
509 const num = parseInt(c.req.param("number"), 10);
510
511 const resolved = await resolveRepo(owner, repo);
512 if (!resolved) return c.json({ error: "Not found" }, 404);
513
514 const [pr] = await db
515 .select()
516 .from(pullRequests)
517 .where(and(eq(pullRequests.repositoryId, (resolved.repo as any).id), eq(pullRequests.number, num)))
518 .limit(1);
519
520 if (!pr) return c.json({ error: "PR not found" }, 404);
521
522 const comments = await db
523 .select({
524 comment: prComments,
525 author: { username: users.username },
526 })
527 .from(prComments)
528 .innerJoin(users, eq(prComments.authorId, users.id))
529 .where(eq(prComments.pullRequestId, pr.id))
530 .orderBy(asc(prComments.createdAt));
531
532 return c.json({
533 ...pr,
534 comments: comments.map(({ comment, author }) => ({ ...comment, author })),
535 });
536});
537
538// ─── Stars ──────────────────────────────────────────────────────────────────
539
540apiv2.put("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
541 const { owner, repo } = c.req.param();
542 const user = c.get("user")!;
543
544 const resolved = await resolveRepo(owner, repo);
545 if (!resolved) return c.json({ error: "Not found" }, 404);
546
547 const repoId = (resolved.repo as any).id;
548 const [existing] = await db
549 .select()
550 .from(stars)
551 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
552 .limit(1);
553
554 if (!existing) {
555 await db.insert(stars).values({ userId: user.id, repositoryId: repoId });
556 await db
557 .update(repositories)
558 .set({ starCount: sql`${repositories.starCount} + 1` })
559 .where(eq(repositories.id, repoId));
560 }
561
562 return c.json({ starred: true });
563});
564
565apiv2.delete("/repos/:owner/:repo/star", requireApiAuth, async (c) => {
566 const { owner, repo } = c.req.param();
567 const user = c.get("user")!;
568
569 const resolved = await resolveRepo(owner, repo);
570 if (!resolved) return c.json({ error: "Not found" }, 404);
571
572 const repoId = (resolved.repo as any).id;
573 const [existing] = await db
574 .select()
575 .from(stars)
576 .where(and(eq(stars.userId, user.id), eq(stars.repositoryId, repoId)))
577 .limit(1);
578
579 if (existing) {
580 await db.delete(stars).where(eq(stars.id, existing.id));
581 await db
582 .update(repositories)
583 .set({ starCount: sql`GREATEST(${repositories.starCount} - 1, 0)` })
584 .where(eq(repositories.id, repoId));
585 }
586
587 return c.json({ starred: false });
588});
589
590// ─── Labels ─────────────────────────────────────────────────────────────────
591
592apiv2.get("/repos/:owner/:repo/labels", async (c) => {
593 const { owner, repo } = c.req.param();
594 const resolved = await resolveRepo(owner, repo);
595 if (!resolved) return c.json({ error: "Not found" }, 404);
596
597 const labelList = await db
598 .select()
599 .from(labels)
600 .where(eq(labels.repositoryId, (resolved.repo as any).id))
601 .orderBy(asc(labels.name));
602
603 return c.json(labelList);
604});
605
606apiv2.post("/repos/:owner/:repo/labels", requireApiAuth, requireScope("repo"), async (c) => {
607 const { owner, repo } = c.req.param();
608 const body = await c.req.json<{ name: string; color?: string; description?: string }>();
609
610 if (!body.name?.trim()) {
611 return c.json({ error: "Label name is required" }, 400);
612 }
613
614 const resolved = await resolveRepo(owner, repo);
615 if (!resolved) return c.json({ error: "Not found" }, 404);
616
617 const result = await db
618 .insert(labels)
619 .values({
620 repositoryId: (resolved.repo as any).id,
621 name: body.name.trim(),
622 color: body.color || "#8b949e",
623 description: body.description || null,
624 })
625 .returning();
626
627 return c.json(result[0], 201);
628});
629
630// ─── Search ─────────────────────────────────────────────────────────────────
631
632apiv2.get("/search/repos", searchRateLimit, async (c) => {
633 const q = c.req.query("q") || "";
634 const sort = c.req.query("sort") || "stars";
635 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
636
637 if (!q.trim()) {
638 return c.json({ error: "Query parameter 'q' is required" }, 400);
639 }
640
641 const orderBy = sort === "updated" ? desc(repositories.updatedAt) :
642 sort === "name" ? asc(repositories.name) :
643 desc(repositories.starCount);
644
645 const results = await db
646 .select({
647 repo: repositories,
648 owner: { username: users.username },
649 })
650 .from(repositories)
651 .innerJoin(users, eq(repositories.ownerId, users.id))
652 .where(
653 and(
654 eq(repositories.isPrivate, false),
655 or(
656 like(repositories.name, `%${q}%`),
657 like(repositories.description, `%${q}%`)
658 )
659 )
660 )
661 .orderBy(orderBy)
662 .limit(limit);
663
664 return c.json(results.map(({ repo, owner }) => ({ ...repo, owner })));
665});
666
667apiv2.get("/repos/:owner/:repo/search/code", searchRateLimit, async (c) => {
668 const { owner, repo } = c.req.param();
669 const q = c.req.query("q") || "";
670
671 if (!q.trim()) return c.json({ error: "Query parameter 'q' is required" }, 400);
672 if (!(await repoExists(owner, repo))) return c.json({ error: "Not found" }, 404);
673
674 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
675 const results = await searchCode(owner, repo, defaultBranch, q.trim());
676 return c.json(results);
677});
678
679// ─── Activity Feed ──────────────────────────────────────────────────────────
680
681apiv2.get("/repos/:owner/:repo/activity", async (c) => {
682 const { owner, repo } = c.req.param();
683 const limit = Math.min(parseInt(c.req.query("limit") || "30"), 100);
684
685 const resolved = await resolveRepo(owner, repo);
686 if (!resolved) return c.json({ error: "Not found" }, 404);
687
688 const activity = await db
689 .select()
690 .from(activityFeed)
691 .where(eq(activityFeed.repositoryId, (resolved.repo as any).id))
692 .orderBy(desc(activityFeed.createdAt))
693 .limit(limit);
694
695 return c.json(activity);
696});
697
698// ─── Topics ─────────────────────────────────────────────────────────────────
699
700apiv2.get("/repos/:owner/:repo/topics", async (c) => {
701 const { owner, repo } = c.req.param();
702 const resolved = await resolveRepo(owner, repo);
703 if (!resolved) return c.json({ error: "Not found" }, 404);
704
705 const topicsList = await db
706 .select()
707 .from(repoTopics)
708 .where(eq(repoTopics.repositoryId, (resolved.repo as any).id));
709
710 return c.json(topicsList.map((t: any) => t.topic));
711});
712
713apiv2.put("/repos/:owner/:repo/topics", requireApiAuth, requireScope("repo"), async (c) => {
714 const { owner, repo } = c.req.param();
715 const user = c.get("user")!;
716 const body = await c.req.json<{ topics: string[] }>();
717
718 const resolved = await resolveRepo(owner, repo);
719 if (!resolved) return c.json({ error: "Not found" }, 404);
720 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
721
722 const repoId = (resolved.repo as any).id;
723
724 // Clear existing topics and insert new ones
725 await db.delete(repoTopics).where(eq(repoTopics.repositoryId, repoId));
726 if (body.topics && body.topics.length > 0) {
727 await db.insert(repoTopics).values(
728 body.topics.slice(0, 20).map((topic: string) => ({
729 repositoryId: repoId,
730 topic: topic.toLowerCase().trim(),
731 }))
732 );
733 }
734
735 return c.json({ ok: true });
736});
737
738// ─── Webhooks ───────────────────────────────────────────────────────────────
739
740apiv2.get("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("repo"), async (c) => {
741 const { owner, repo } = c.req.param();
742 const user = c.get("user")!;
743
744 const resolved = await resolveRepo(owner, repo);
745 if (!resolved) return c.json({ error: "Not found" }, 404);
746 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
747
748 const hookList = await db
749 .select()
750 .from(webhooks)
751 .where(eq(webhooks.repositoryId, (resolved.repo as any).id));
752
753 return c.json(hookList);
754});
755
756apiv2.post("/repos/:owner/:repo/webhooks", requireApiAuth, requireScope("admin"), async (c) => {
757 const { owner, repo } = c.req.param();
758 const user = c.get("user")!;
759 const body = await c.req.json<{
760 url: string;
761 secret?: string;
762 events?: string;
763 }>();
764
765 if (!body.url) return c.json({ error: "URL is required" }, 400);
766
767 const resolved = await resolveRepo(owner, repo);
768 if (!resolved) return c.json({ error: "Not found" }, 404);
769 if (user.id !== resolved.owner.id) return c.json({ error: "Permission denied" }, 403);
770
771 const result = await db
772 .insert(webhooks)
773 .values({
774 repositoryId: (resolved.repo as any).id,
775 url: body.url,
776 secret: body.secret || null,
777 events: body.events || "push",
778 })
779 .returning();
780
781 return c.json(result[0], 201);
782});
783
784// ─── API Info ───────────────────────────────────────────────────────────────
785
786apiv2.get("/", (c) => {
787 return c.json({
788 name: "gluecron API",
789 version: "2.0",
790 documentation: "/api/docs",
791 endpoints: {
792 users: {
793 "GET /api/v2/user": "Get authenticated user",
794 "GET /api/v2/users/:username": "Get user by username",
795 "PATCH /api/v2/user": "Update authenticated user profile",
796 },
797 repositories: {
798 "GET /api/v2/users/:username/repos": "List user repositories",
799 "POST /api/v2/repos": "Create repository",
800 "GET /api/v2/repos/:owner/:repo": "Get repository",
801 "PATCH /api/v2/repos/:owner/:repo": "Update repository",
802 "DELETE /api/v2/repos/:owner/:repo": "Delete repository",
803 },
804 branches: {
805 "GET /api/v2/repos/:owner/:repo/branches": "List branches",
806 },
807 commits: {
808 "GET /api/v2/repos/:owner/:repo/commits": "List commits",
809 "GET /api/v2/repos/:owner/:repo/commits/:sha": "Get commit with diff",
810 },
811 files: {
812 "GET /api/v2/repos/:owner/:repo/tree/:ref": "Get file tree",
813 "GET /api/v2/repos/:owner/:repo/contents/:path": "Get file contents",
814 },
815 issues: {
816 "GET /api/v2/repos/:owner/:repo/issues": "List issues",
817 "POST /api/v2/repos/:owner/:repo/issues": "Create issue",
818 "GET /api/v2/repos/:owner/:repo/issues/:number": "Get issue with comments",
819 "PATCH /api/v2/repos/:owner/:repo/issues/:number": "Update issue",
820 "POST /api/v2/repos/:owner/:repo/issues/:number/comments": "Add comment",
821 },
822 pullRequests: {
823 "GET /api/v2/repos/:owner/:repo/pulls": "List pull requests",
824 "POST /api/v2/repos/:owner/:repo/pulls": "Create pull request",
825 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
826 },
827 stars: {
828 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
829 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
830 },
831 labels: {
832 "GET /api/v2/repos/:owner/:repo/labels": "List labels",
833 "POST /api/v2/repos/:owner/:repo/labels": "Create label",
834 },
835 search: {
836 "GET /api/v2/search/repos": "Search repositories",
837 "GET /api/v2/repos/:owner/:repo/search/code": "Search code in repo",
838 },
839 topics: {
840 "GET /api/v2/repos/:owner/:repo/topics": "Get topics",
841 "PUT /api/v2/repos/:owner/:repo/topics": "Set topics",
842 },
843 webhooks: {
844 "GET /api/v2/repos/:owner/:repo/webhooks": "List webhooks",
845 "POST /api/v2/repos/:owner/:repo/webhooks": "Create webhook",
846 },
847 activity: {
848 "GET /api/v2/repos/:owner/:repo/activity": "Get activity feed",
849 },
850 },
851 authentication: {
852 method: "Bearer token",
853 header: "Authorization: Bearer <your-token>",
854 createToken: "Visit /settings/tokens to create a personal access token",
855 },
856 rateLimit: {
857 api: "100 requests/minute",
858 search: "30 requests/minute",
859 auth: "10 requests/minute",
860 },
861 });
862});
863
864export default apiv2;
Modifiedsrc/routes/api.ts+17−0View fileUnifiedSplit
@@ -135,6 +135,23 @@ api.post("/repos", async (c) => {
135135 console.error("[api] POST /repos:", err);
136136 return c.json({ error: "Service unavailable" }, 503);
137137 }
138
139 // Init bare repo on disk
140 const diskPath = await initBareRepo(body.owner, body.name);
141
142 // Insert into DB
143 const result = await db
144 .insert(repositories)
145 .values({
146 name: body.name,
147 ownerId: owner.id,
148 description: body.description || null,
149 isPrivate: body.isPrivate || false,
150 diskPath,
151 })
152 .returning();
153
154 return c.json(result[0], 201);
138155});
139156
140157// List user's repositories
Modifiedsrc/routes/auth.tsx+29−60View fileUnifiedSplit
@@ -23,6 +23,14 @@ import {
2323import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
2424import { getSsoConfig } from "../lib/sso";
2525import { Layout } from "../views/layout";
26import {
27 Form,
28 FormGroup,
29 Input,
30 Button,
31 Alert,
32 Text,
33} from "../views/ui";
2634import type { AuthEnv } from "../middleware/auth";
2735
2836const auth = new Hono<AuthEnv>();
@@ -41,7 +49,6 @@ auth.get("/register", (c) => {
4149 <label for="username">Username</label>
4250 <input
4351 type="text"
44 id="username"
4552 name="username"
4653 required
4754 pattern="^[a-zA-Z0-9_-]+$"
@@ -50,36 +57,32 @@ auth.get("/register", (c) => {
5057 placeholder="your-username"
5158 autocomplete="username"
5259 />
53 </div>
54 <div class="form-group">
55 <label for="email">Email</label>
56 <input
60 </FormGroup>
61 <FormGroup label="Email" htmlFor="email">
62 <Input
5763 type="email"
58 id="email"
5964 name="email"
6065 required
6166 placeholder="you@example.com"
6267 autocomplete="email"
6368 />
64 </div>
65 <div class="form-group">
66 <label for="password">Password</label>
67 <input
69 </FormGroup>
70 <FormGroup label="Password" htmlFor="password">
71 <Input
6872 type="password"
69 id="password"
7073 name="password"
7174 required
7275 minLength={8}
7376 placeholder="Min 8 characters"
7477 autocomplete="new-password"
7578 />
76 </div>
77 <button type="submit" class="btn btn-primary">
79 </FormGroup>
80 <Button type="submit" variant="primary">
7881 Create account
79 </button>
80 </form>
82 </Button>
83 </Form>
8184 <p class="auth-switch">
82 Already have an account? <a href="/login">Sign in</a>
85 <Text>Already have an account? <a href="/login">Sign in</a></Text>
8386 </p>
8487 </div>
8588 </Layout>
@@ -177,64 +180,30 @@ auth.get("/login", async (c) => {
177180 method="post"
178181 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
179182 >
180 <div class="form-group">
181 <label for="username">Username or email</label>
182 <input
183 <FormGroup label="Username or email" htmlFor="username">
184 <Input
183185 type="text"
184 id="username"
185186 name="username"
186187 required
187188 placeholder="username or email"
188189 autocomplete="username"
189190 />
190 </div>
191 <div class="form-group">
192 <label for="password">Password</label>
193 <input
191 </FormGroup>
192 <FormGroup label="Password" htmlFor="password">
193 <Input
194194 type="password"
195 id="password"
196195 name="password"
197196 required
198197 placeholder="Password"
199198 autocomplete="current-password"
200199 />
201 </div>
202 <button type="submit" class="btn btn-primary">
200 </FormGroup>
201 <Button type="submit" variant="primary">
203202 Sign in
204 </button>
205 </form>
206 <div
207 style="margin: 16px 0; text-align: center; color: var(--text-muted); font-size: 12px"
208 >
209 — or —
210 </div>
211 <div style="text-align: center">
212 <button
213 type="button"
214 id="pk-signin-btn"
215 class="btn"
216 style="width: 100%"
217 >
218 Sign in with passkey
219 </button>
220 <div
221 id="pk-signin-status"
222 style="margin-top: 8px; color: var(--text-muted); font-size: 12px; min-height: 16px"
223 />
224 </div>
225 {ssoEnabled && (
226 <div style="margin-top: 8px; text-align: center">
227 <a
228 href="/login/sso"
229 class="btn"
230 style="width: 100%; display: block"
231 >
232 Sign in with {ssoLabel}
233 </a>
234 </div>
235 )}
203 </Button>
204 </Form>
236205 <p class="auth-switch">
237 New to gluecron? <a href="/register">Create an account</a>
206 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
238207 </p>
239208 <script
240209 dangerouslySetInnerHTML={{
Modifiedsrc/routes/compare.tsx+32−28View fileUnifiedSplit
@@ -17,6 +17,11 @@ import {
1717import { softAuth } from "../middleware/auth";
1818import type { AuthEnv } from "../middleware/auth";
1919import type { GitDiffFile } from "../git/repository";
20import {
21 EmptyState,
22 Flex,
23 Text,
24} from "../views/ui";
2025
2126const compare = new Hono<AuthEnv>();
2227
@@ -30,9 +35,7 @@ compare.get("/:owner/:repo/compare/:spec?", async (c) => {
3035 if (!(await repoExists(owner, repo))) {
3136 return c.html(
3237 <Layout title="Not Found" user={user}>
33 <div class="empty-state">
34 <h2>Repository not found</h2>
35 </div>
38 <EmptyState title="Repository not found" />
3639 </Layout>,
3740 404
3841 );
@@ -51,30 +54,31 @@ compare.get("/:owner/:repo/compare/:spec?", async (c) => {
5154 <form
5255 method="get"
5356 action={`/${owner}/${repo}/compare`}
54 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px"
5557 >
56 <select name="base" class="branch-selector" style="cursor: pointer">
57 {branches.map((b) => (
58 <option value={b} selected={b === defaultBase}>
59 {b}
60 </option>
61 ))}
62 </select>
63 <span style="color: var(--text-muted)">...</span>
64 <select name="head" class="branch-selector" style="cursor: pointer">
65 {branches.map((b) => (
66 <option value={b} selected={b !== defaultBase}>
67 {b}
68 </option>
69 ))}
70 </select>
71 <button
72 type="submit"
73 class="btn btn-primary"
74 onclick={`this.form.action='/${owner}/${repo}/compare/'+this.form.base.value+'...'+this.form.head.value; return true;`}
75 >
76 Compare
77 </button>
58 <Flex gap={12} align="center" style="margin-bottom: 20px">
59 <select name="base" class="branch-selector" style="cursor: pointer">
60 {branches.map((b) => (
61 <option value={b} selected={b === defaultBase}>
62 {b}
63 </option>
64 ))}
65 </select>
66 <Text muted>...</Text>
67 <select name="head" class="branch-selector" style="cursor: pointer">
68 {branches.map((b) => (
69 <option value={b} selected={b !== defaultBase}>
70 {b}
71 </option>
72 ))}
73 </select>
74 <button
75 type="submit"
76 class="btn btn-primary"
77 onclick={`this.form.action='/${owner}/${repo}/compare/'+this.form.base.value+'...'+this.form.head.value; return true;`}
78 >
79 Compare
80 </button>
81 </Flex>
7882 </form>
7983 </Layout>
8084 );
@@ -143,9 +147,9 @@ compare.get("/:owner/:repo/compare/:spec?", async (c) => {
143147 <h2 style="margin-bottom: 8px">
144148 Comparing {base}...{head}
145149 </h2>
146 <div style="margin-bottom: 20px; font-size: 14px; color: var(--text-muted)">
150 <Text size={14} muted style="display:block;margin-bottom:20px">
147151 {commitsBetween.length} commit{commitsBetween.length !== 1 ? "s" : ""}
148 </div>
152 </Text>
149153
150154 {commitsBetween.length > 0 && (
151155 <div class="commit-list" style="margin-bottom: 24px">
Modifiedsrc/routes/contributors.tsx+42−37View fileUnifiedSplit
@@ -8,6 +8,16 @@ import { RepoHeader, RepoNav } from "../views/components";
88import { getRepoPath, repoExists, getDefaultBranch } from "../git/repository";
99import { softAuth } from "../middleware/auth";
1010import type { AuthEnv } from "../middleware/auth";
11import {
12 Avatar,
13 Card,
14 Flex,
15 List,
16 ListItem,
17 PageHeader,
18 Text,
19 Tooltip,
20} from "../views/ui";
1121
1222const contributors = new Hono<AuthEnv>();
1323
@@ -89,52 +99,47 @@ contributors.get("/:owner/:repo/contributors", async (c) => {
8999 <Layout title={`Contributors — ${owner}/${repo}`} user={user}>
90100 <RepoHeader owner={owner} repo={repo} />
91101 <RepoNav owner={owner} repo={repo} active="code" />
92 <h2 style="margin-bottom: 16px">Contributors</h2>
93
94 <div
95 style="margin-bottom: 24px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px"
96 >
97 <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 8px">
98 Commit activity — last year
99 </div>
100 <div style="display: flex; gap: 2px; align-items: flex-end; height: 60px">
102 <PageHeader title="Contributors" />
103
104 <Card style="margin-bottom:24px;padding:16px">
105 <Text size={13} muted>Commit activity — last year</Text>
106 <Flex gap={2} align="flex-end" style="height:60px;margin-top:8px">
101107 {weekCounts.map((count) => (
102 <div
103 style={`flex: 1; background: var(--green); opacity: ${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)}; height: ${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"}; border-radius: 1px;`}
104 title={`${count} commits`}
105 />
108 <Tooltip text={`${count} commits`}>
109 <div
110 style={`flex:1;background:var(--green);opacity:${count === 0 ? "0.1" : Math.max(0.3, count / maxWeek).toFixed(2)};height:${count === 0 ? "2px" : Math.max(4, (count / maxWeek) * 60).toFixed(0) + "px"};border-radius:1px;`}
111 />
112 </Tooltip>
106113 ))}
107 </div>
108 </div>
109
110 <div class="issue-list">
111 {contribs.map((contrib, i) => (
112 <div class="issue-item">
113 <div style="display: flex; align-items: center; gap: 12px; flex: 1">
114 <div class="user-avatar" style="width: 36px; height: 36px; font-size: 16px; flex-shrink: 0">
115 {contrib.name[0].toUpperCase()}
116 </div>
114 </Flex>
115 </Card>
116
117 <List>
118 {contribs.map((contrib) => (
119 <ListItem>
120 <Flex align="center" gap={12} style="flex:1">
121 <Avatar name={contrib.name} size={36} />
117122 <div>
118 <div style="font-weight: 600; font-size: 14px">
123 <Text size={14} weight={600}>
119124 {contrib.name}
120 </div>
121 <div style="font-size: 12px; color: var(--text-muted)">
125 </Text>
126 <br />
127 <Text size={12} muted>
122128 {contrib.email}
123 </div>
129 </Text>
124130 </div>
125 </div>
126 <div style="text-align: right">
127 <span style="font-weight: 600; font-size: 14px">
131 </Flex>
132 <div style="text-align:right">
133 <Text size={14} weight={600}>
128134 {contrib.commits}
129 </span>
130 <span style="color: var(--text-muted); font-size: 13px">
131 {" "}
132 commit{contrib.commits !== 1 ? "s" : ""}
133 </span>
135 </Text>
136 <Text size={13} muted>
137 {" "}commit{contrib.commits !== 1 ? "s" : ""}
138 </Text>
134139 </div>
135 </div>
140 </ListItem>
136141 ))}
137 </div>
142 </List>
138143 </Layout>
139144 );
140145});
Modifiedsrc/routes/editor.tsx+49−45View fileUnifiedSplit
@@ -5,6 +5,18 @@
55import { Hono } from "hono";
66import { Layout } from "../views/layout";
77import { RepoHeader, RepoNav, Breadcrumb } from "../views/components";
8import {
9 Container,
10 Flex,
11 Form,
12 FormGroup,
13 Input,
14 TextArea,
15 Button,
16 LinkButton,
17 EmptyState,
18 Text,
19} from "../views/ui";
820import {
921 getBlob,
1022 getDefaultBranch,
@@ -34,51 +46,47 @@ editor.get("/:owner/:repo/new/:ref{.+$}", requireAuth, async (c) => {
3446 <Layout title={`New file — ${owner}/${repo}`} user={user}>
3547 <RepoHeader owner={owner} repo={repo} />
3648 <RepoNav owner={owner} repo={repo} active="code" />
37 <div style="max-width: 900px">
49 <Container maxWidth={900}>
3850 <h2 style="margin-bottom: 16px">Create new file</h2>
3951 <form method="post" action={`/${owner}/${repo}/new/${ref}`}>
4052 <input type="hidden" name="dir_path" value={dirPath} />
41 <div class="form-group">
42 <label>File path</label>
43 <div style="display: flex; align-items: center; gap: 4px">
53 <FormGroup label="File path">
54 <Flex align="center" gap={4}>
4455 {dirPath && (
45 <span style="color: var(--text-muted); font-size: 14px">
56 <Text muted size={14}>
4657 {dirPath}/
47 </span>
58 </Text>
4859 )}
49 <input
50 type="text"
60 <Input
5161 name="filename"
5262 required
5363 placeholder="filename.ts"
5464 style="flex: 1"
5565 autocomplete="off"
5666 />
57 </div>
58 </div>
59 <div class="form-group">
60 <label>Content</label>
61 <textarea
67 </Flex>
68 </FormGroup>
69 <FormGroup label="Content">
70 <TextArea
6271 name="content"
6372 rows={20}
64 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2"
6573 placeholder="Enter file content..."
74 mono
75 style="line-height: 1.5; tab-size: 2"
6676 />
67 </div>
68 <div class="form-group">
69 <label>Commit message</label>
70 <input
71 type="text"
77 </FormGroup>
78 <FormGroup label="Commit message">
79 <Input
7280 name="message"
7381 placeholder="Create new file"
7482 required
7583 />
76 </div>
77 <button type="submit" class="btn btn-primary">
84 </FormGroup>
85 <Button type="submit" variant="primary">
7886 Commit new file
79 </button>
80 </form>
81 </div>
87 </Button>
88 </Form>
89 </Container>
8290 </Layout>
8391 );
8492});
@@ -196,9 +204,7 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
196204 if (!blob || blob.isBinary) {
197205 return c.html(
198206 <Layout title="Cannot edit" user={user}>
199 <div class="empty-state">
200 <h2>{blob?.isBinary ? "Cannot edit binary file" : "File not found"}</h2>
201 </div>
207 <EmptyState title={blob?.isBinary ? "Cannot edit binary file" : "File not found"} />
202208 </Layout>,
203209 404
204210 );
@@ -215,30 +221,28 @@ editor.get("/:owner/:repo/edit/:ref{.+$}", requireAuth, async (c) => {
215221 <textarea
216222 name="content"
217223 rows={25}
218 style="font-family: var(--font-mono); font-size: 13px; line-height: 1.5; tab-size: 2; width: 100%"
219 >
220 {blob.content}
221 </textarea>
222 </div>
223 <div class="form-group">
224 <label>Commit message</label>
225 <input
226 type="text"
224 value={blob.content}
225 mono
226 style="line-height: 1.5; tab-size: 2; width: 100%"
227 />
228 </FormGroup>
229 <FormGroup label="Commit message">
230 <Input
227231 name="message"
228232 placeholder={`Update ${filePath.split("/").pop()}`}
229233 required
230234 />
231 </div>
232 <div style="display: flex; gap: 8px">
233 <button type="submit" class="btn btn-primary">
235 </FormGroup>
236 <Flex gap={8}>
237 <Button type="submit" variant="primary">
234238 Commit changes
235 </button>
236 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} class="btn">
239 </Button>
240 <LinkButton href={`/${owner}/${repo}/blob/${ref}/${filePath}`}>
237241 Cancel
238 </a>
239 </div>
240 </form>
241 </div>
242 </LinkButton>
243 </Flex>
244 </Form>
245 </Container>
242246 </Layout>
243247 );
244248});
Modifiedsrc/routes/explore.tsx+40−28View fileUnifiedSplit
@@ -10,6 +10,16 @@ import { Layout } from "../views/layout";
1010import { RepoCard } from "../views/components";
1111import { softAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
13import {
14 Flex,
15 Grid,
16 Badge,
17 Button,
18 LinkButton,
19 Input,
20 EmptyState,
21 PageHeader,
22} from "../views/ui";
1323
1424const explore = new Hono<AuthEnv>();
1525
@@ -99,72 +109,74 @@ explore.get("/explore", async (c) => {
99109
100110 return c.html(
101111 <Layout title="Explore" user={user}>
102 <h2 style="margin-bottom: 16px">Explore repositories</h2>
103 <div style="display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; align-items: center">
112 <PageHeader title="Explore repositories" />
113 <Flex gap={12} wrap align="center" style="margin-bottom:24px">
104114 <form
105115 method="get"
106116 action="/explore"
107 style="display: flex; gap: 8px; flex: 1; min-width: 250px"
117 style="display:flex;gap:8px;flex:1;min-width:250px"
108118 >
109 <input
110 type="text"
119 <Input
111120 name="q"
112121 value={q}
113122 placeholder="Search repositories..."
114 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
123 style="flex:1;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px"
115124 />
116 <button type="submit" class="btn btn-primary">
125 <Button type="submit" variant="primary">
117126 Search
118 </button>
127 </Button>
119128 </form>
120 <div style="display: flex; gap: 8px">
121 <a
129 <Flex gap={8}>
130 <LinkButton
122131 href="/explore?sort=recent"
123 class={`btn btn-sm ${sort === "recent" && !q ? "btn-primary" : ""}`}
132 size="sm"
133 variant={sort === "recent" && !q ? "primary" : "default"}
124134 >
125135 Recent
126 </a>
127 <a
136 </LinkButton>
137 <LinkButton
128138 href="/explore?sort=stars"
129 class={`btn btn-sm ${sort === "stars" ? "btn-primary" : ""}`}
139 size="sm"
140 variant={sort === "stars" ? "primary" : "default"}
130141 >
131142 Most stars
132 </a>
133 <a
143 </LinkButton>
144 <LinkButton
134145 href="/explore?sort=forks"
135 class={`btn btn-sm ${sort === "forks" ? "btn-primary" : ""}`}
146 size="sm"
147 variant={sort === "forks" ? "primary" : "default"}
136148 >
137149 Most forks
138 </a>
139 </div>
140 </div>
150 </LinkButton>
151 </Flex>
152 </Flex>
141153 {topic && (
142 <div style="margin-bottom: 16px">
143 <span class="badge" style="font-size: 14px; padding: 4px 12px">
154 <div style="margin-bottom:16px">
155 <Badge style="font-size:14px;padding:4px 12px">
144156 Topic: {topic}
145 </span>
157 </Badge>
146158 <a
147159 href="/explore"
148 style="margin-left: 8px; font-size: 13px; color: var(--text-muted)"
160 style="margin-left:8px;font-size:13px;color:var(--text-muted)"
149161 >
150162 Clear
151163 </a>
152164 </div>
153165 )}
154166 {repoList.length === 0 ? (
155 <div class="empty-state">
167 <EmptyState>
156168 <p>
157169 {q
158170 ? `No repositories matching "${q}"`
159171 : "No public repositories yet."}
160172 </p>
161 </div>
173 </EmptyState>
162174 ) : (
163 <div class="card-grid">
175 <Grid>
164176 {repoList.map(({ repo, ownerName }) => (
165177 <RepoCard repo={repo} ownerName={ownerName} />
166178 ))}
167 </div>
179 </Grid>
168180 )}
169181 </Layout>
170182 );
Modifiedsrc/routes/git.ts+9−0View fileUnifiedSplit
@@ -13,6 +13,15 @@ import { trackByName } from "../lib/traffic";
1313
1414const git = new Hono();
1515
16/** Extract repo name from the ":repo.git" param Hono generates. */
17function gitParams(c: any): { owner: string; repo: string } {
18 const params = c.req.param();
19 const owner: string = params.owner;
20 const raw: string = params["repo.git"] ?? params.repo ?? "";
21 const repo = raw.replace(/\.git$/, "");
22 return { owner, repo };
23}
24
1625// Discovery: GET /:owner/:repo.git/info/refs?service=...
1726git.get("/:owner/:repo.git/info/refs", async (c) => {
1827 const { owner, "repo.git": repo } = c.req.param();
Modifiedsrc/routes/issues.tsx+88−135View fileUnifiedSplit
@@ -21,7 +21,27 @@ import { loadIssueTemplate } from "../lib/templates";
2121import { renderMarkdown } from "../lib/markdown";
2222import { softAuth, requireAuth } from "../middleware/auth";
2323import type { AuthEnv } from "../middleware/auth";
24import { html } from "hono/html";
24import {
25 Flex,
26 Container,
27 PageHeader,
28 Form,
29 FormGroup,
30 Input,
31 TextArea,
32 Button,
33 LinkButton,
34 Badge,
35 EmptyState,
36 TabNav,
37 FilterTabs,
38 List,
39 ListItem,
40 Alert,
41 CommentBox,
42 CommentForm,
43 formatRelative,
44} from "../views/ui";
2545
2646const issueRoutes = new Hono<AuthEnv>();
2747
@@ -56,9 +76,7 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
5676 if (!resolved) {
5777 return c.html(
5878 <Layout title="Not Found" user={user}>
59 <div class="empty-state">
60 <h2>Repository not found</h2>
61 </div>
79 <EmptyState title="Repository not found" />
6280 </Layout>,
6381 404
6482 );
@@ -91,32 +109,32 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
91109 <Layout title={`Issues — ${ownerName}/${repoName}`} user={user}>
92110 <RepoHeader owner={ownerName} repo={repoName} />
93111 <IssueNav owner={ownerName} repo={repoName} active="issues" />
94 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
95 <div class="issue-tabs">
96 <a
97 href={`/${ownerName}/${repoName}/issues?state=open`}
98 class={state === "open" ? "active" : ""}
99 >
100 {counts?.open ?? 0} Open
101 </a>
102 <a
103 href={`/${ownerName}/${repoName}/issues?state=closed`}
104 class={state === "closed" ? "active" : ""}
105 >
106 {counts?.closed ?? 0} Closed
107 </a>
108 </div>
112 <Flex justify="space-between" align="center" style="margin-bottom:16px">
113 <FilterTabs
114 tabs={[
115 {
116 label: `${counts?.open ?? 0} Open`,
117 href: `/${ownerName}/${repoName}/issues?state=open`,
118 active: state === "open",
119 },
120 {
121 label: `${counts?.closed ?? 0} Closed`,
122 href: `/${ownerName}/${repoName}/issues?state=closed`,
123 active: state === "closed",
124 },
125 ]}
126 />
109127 {user && (
110 <a
128 <LinkButton
111129 href={`/${ownerName}/${repoName}/issues/new`}
112 class="btn btn-primary"
130 variant="primary"
113131 >
114132 New issue
115 </a>
133 </LinkButton>
116134 )}
117 </div>
135 </Flex>
118136 {issueList.length === 0 ? (
119 <div class="empty-state">
137 <EmptyState>
120138 <p>
121139 No {state} issues.
122140 {state === "closed" && (
@@ -128,11 +146,11 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
128146 </span>
129147 )}
130148 </p>
131 </div>
149 </EmptyState>
132150 ) : (
133 <div class="issue-list">
151 <List>
134152 {issueList.map(({ issue, author }) => (
135 <div class="issue-item">
153 <ListItem>
136154 <div
137155 class={`issue-state-icon ${issue.state === "open" ? "state-open" : "state-closed"}`}
138156 >
@@ -149,9 +167,9 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, async (c) => {
149167 {formatRelative(issue.createdAt)}
150168 </div>
151169 </div>
152 </div>
170 </ListItem>
153171 ))}
154 </div>
172 </List>
155173 )}
156174 </Layout>
157175 );
@@ -172,15 +190,10 @@ issueRoutes.get(
172190 <Layout title={`New issue — ${ownerName}/${repoName}`} user={user}>
173191 <RepoHeader owner={ownerName} repo={repoName} />
174192 <IssueNav owner={ownerName} repo={repoName} active="issues" />
175 <div style="max-width: 800px">
176 <h2 style="margin-bottom: 16px">New issue</h2>
177 {template && (
178 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
179 Using <code>ISSUE_TEMPLATE.md</code> from the default branch.
180 </div>
181 )}
193 <Container maxWidth={800}>
194 <h2 style="margin-bottom:16px">New issue</h2>
182195 {error && (
183 <div class="auth-error">{decodeURIComponent(error)}</div>
196 <Alert variant="error">{decodeURIComponent(error)}</Alert>
184197 )}
185198 <form method="post" action={`/${ownerName}/${repoName}/issues/new`}>
186199 <div class="form-group">
@@ -189,24 +202,22 @@ issueRoutes.get(
189202 name="title"
190203 required
191204 placeholder="Title"
192 style="font-size: 16px; padding: 10px 14px"
205 style="font-size:16px;padding:10px 14px"
193206 />
194 </div>
195 <div class="form-group">
196 <textarea
207 </FormGroup>
208 <FormGroup>
209 <TextArea
197210 name="body"
198211 rows={12}
199212 placeholder="Leave a comment... (Markdown supported)"
200 style="font-family: var(--font-mono); font-size: 13px"
201 >
202 {template || ""}
203 </textarea>
204 </div>
205 <button type="submit" class="btn btn-primary">
213 mono
214 />
215 </FormGroup>
216 <Button type="submit" variant="primary">
206217 Submit new issue
207 </button>
208 </form>
209 </div>
218 </Button>
219 </Form>
220 </Container>
210221 </Layout>
211222 );
212223 }
@@ -265,9 +276,7 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
265276 if (!resolved) {
266277 return c.html(
267278 <Layout title="Not Found" user={user}>
268 <div class="empty-state">
269 <h2>Not found</h2>
270 </div>
279 <EmptyState title="Not found" />
271280 </Layout>,
272281 404
273282 );
@@ -287,9 +296,7 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
287296 if (!issue) {
288297 return c.html(
289298 <Layout title="Not Found" user={user}>
290 <div class="empty-state">
291 <h2>Issue not found</h2>
292 </div>
299 <EmptyState title="Issue not found" />
293300 </Layout>,
294301 404
295302 );
@@ -334,62 +341,36 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
334341 <div class="issue-detail">
335342 <h2>
336343 {issue.title}{" "}
337 <span style="color: var(--text-muted); font-weight: 400">
344 <span style="color:var(--text-muted);font-weight:400">
338345 #{issue.number}
339346 </span>
340347 </h2>
341 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
342 <span
343 class={`issue-badge ${issue.state === "open" ? "badge-open" : "badge-closed"}`}
344 >
348 <Flex align="center" gap={8} style="margin:8px 0 20px">
349 <Badge variant={issue.state === "open" ? "open" : "closed"}>
345350 {issue.state === "open" ? "\u25CB Open" : "\u2713 Closed"}
346 </span>
347 <span style="color: var(--text-muted); font-size: 14px">
348 <strong style="color: var(--text)">
351 </Badge>
352 <span style="color:var(--text-muted);font-size:14px">
353 <strong style="color:var(--text)">
349354 {author?.username || "unknown"}
350355 </strong>{" "}
351356 opened this issue {formatRelative(issue.createdAt)}
352357 </span>
353 </div>
358 </Flex>
354359
355360 {issue.body && (
356 <div class="issue-comment-box">
357 <div class="comment-header">
358 <strong>{author?.username}</strong> commented{" "}
359 {formatRelative(issue.createdAt)}
360 </div>
361 <div class="markdown-body">
362 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
363 </div>
364 <div style="padding: 0 16px 12px">
365 <ReactionsBar
366 targetType="issue"
367 targetId={issue.id}
368 summaries={issueReactions}
369 canReact={!!user}
370 />
371 </div>
372 </div>
361 <CommentBox
362 author={author?.username || "unknown"}
363 date={issue.createdAt}
364 body={renderMarkdown(issue.body)}
365 />
373366 )}
374367
375 {comments.map(({ comment, author: commentAuthor }, i) => (
376 <div class="issue-comment-box">
377 <div class="comment-header">
378 <strong>{commentAuthor.username}</strong> commented{" "}
379 {formatRelative(comment.createdAt)}
380 </div>
381 <div class="markdown-body">
382 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
383 </div>
384 <div style="padding: 0 16px 12px">
385 <ReactionsBar
386 targetType="issue_comment"
387 targetId={comment.id}
388 summaries={commentReactions[i] || []}
389 canReact={!!user}
390 />
391 </div>
392 </div>
368 {comments.map(({ comment, author: commentAuthor }) => (
369 <CommentBox
370 author={commentAuthor.username}
371 date={comment.createdAt}
372 body={renderMarkdown(comment.body)}
373 />
393374 ))}
394375
395376 {user && (
@@ -543,42 +524,14 @@ const IssueNav = ({
543524 repo: string;
544525 active: "code" | "commits" | "issues";
545526}) => (
546 <div class="repo-nav">
547 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
548 Code
549 </a>
550 <a
551 href={`/${owner}/${repo}/issues`}
552 class={active === "issues" ? "active" : ""}
553 >
554 Issues
555 </a>
556 <a
557 href={`/${owner}/${repo}/commits`}
558 class={active === "commits" ? "active" : ""}
559 >
560 Commits
561 </a>
562 </div>
527 <TabNav
528 tabs={[
529 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
530 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
531 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
532 ]}
533 />
563534);
564535
565function formatRelative(date: Date | string): string {
566 const d = typeof date === "string" ? new Date(date) : date;
567 const now = new Date();
568 const diffMs = now.getTime() - d.getTime();
569 const diffMins = Math.floor(diffMs / 60000);
570 if (diffMins < 1) return "just now";
571 if (diffMins < 60) return `${diffMins}m ago`;
572 const diffHours = Math.floor(diffMins / 60);
573 if (diffHours < 24) return `${diffHours}h ago`;
574 const diffDays = Math.floor(diffHours / 24);
575 if (diffDays < 30) return `${diffDays}d ago`;
576 return d.toLocaleDateString("en-US", {
577 month: "short",
578 day: "numeric",
579 year: "numeric",
580 });
581}
582
583536export default issueRoutes;
584537export { IssueNav };
Modifiedsrc/routes/notifications.tsx+101−198View fileUnifiedSplit
@@ -1,153 +1,71 @@
11/**
2 * Notifications — the central inbox for every event the user needs to act on.
3 *
4 * GET /notifications — full inbox UI, filterable (all / unread / mentions)
5 * POST /notifications/:id/read — mark single notification read
6 * POST /notifications/read-all — mark every unread notification read
7 * POST /notifications/:id/delete — dismiss a notification
8 * GET /api/notifications/unread — JSON count for nav badge
9 * GET /api/notifications — JSON list for third-party integrations
2 * Notification routes — bell icon, list, mark read, clear.
103 */
114
125import { Hono } from "hono";
13import { and, desc, eq, isNull, sql } from "drizzle-orm";
6import { eq, and, desc, sql } from "drizzle-orm";
147import { db } from "../db";
15import { notifications, repositories, users } from "../db/schema";
8import { notifications } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
1610import { Layout } from "../views/layout";
17import { requireAuth, softAuth } from "../middleware/auth";
11import { softAuth, requireAuth } from "../middleware/auth";
1812import type { AuthEnv } from "../middleware/auth";
19
20const notificationsRoute = new Hono<AuthEnv>();
21
22notificationsRoute.use("*", softAuth);
23
24/**
25 * Cheap count of unread notifications — used by the nav badge.
26 * Returns 0 if the user is not logged in or DB is unreachable.
27 */
28export async function getUnreadCount(userId: string): Promise<number> {
13import {
14 Container,
15 PageHeader,
16 Flex,
17 FilterTabs,
18 EmptyState,
19 List,
20 ListItem,
21 Form,
22 Button,
23 Text,
24 formatRelative,
25} from "../views/ui";
26
27const notificationRoutes = new Hono<AuthEnv>();
28
29// Notification list page
30notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
31 const user = c.get("user")!;
32 const filter = c.req.query("filter") || "unread";
33 const csrfToken = (c as any).get("csrfToken") || "";
34
35 const query = db
36 .select()
37 .from(notifications)
38 .where(
39 filter === "all"
40 ? eq(notifications.userId, user.id)
41 : and(eq(notifications.userId, user.id), eq(notifications.isRead, false))
42 )
43 .orderBy(desc(notifications.createdAt))
44 .limit(50);
45
46 let items: any[] = [];
2947 try {
30 const [row] = await db
31 .select({ c: sql<number>`count(*)::int` })
32 .from(notifications)
33 .where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));
34 return row?.c ?? 0;
48 items = await query;
3549 } catch {
36 return 0;
50 // Table may not exist yet
3751 }
38}
39
40function kindBadge(kind: string): { label: string; color: string } {
41 switch (kind) {
42 case "mention":
43 return { label: "@", color: "#58a6ff" };
44 case "review_requested":
45 return { label: "review", color: "#d29922" };
46 case "pr_opened":
47 case "pr_merged":
48 case "pr_closed":
49 return { label: "PR", color: "#986ee2" };
50 case "issue_opened":
51 case "issue_closed":
52 return { label: "issue", color: "#3fb950" };
53 case "gate_failed":
54 return { label: "gate", color: "#f85149" };
55 case "gate_repaired":
56 return { label: "repaired", color: "#bc8cff" };
57 case "gate_passed":
58 return { label: "green", color: "#3fb950" };
59 case "security_alert":
60 return { label: "security", color: "#f85149" };
61 case "deploy_success":
62 return { label: "deploy", color: "#3fb950" };
63 case "deploy_failed":
64 return { label: "deploy", color: "#f85149" };
65 case "release_published":
66 return { label: "release", color: "#58a6ff" };
67 case "ai_review":
68 return { label: "ai", color: "#bc8cff" };
69 default:
70 return { label: kind, color: "#8b949e" };
71 }
72}
73
74function formatRelative(date: Date | string): string {
75 const d = typeof date === "string" ? new Date(date) : date;
76 const diffMs = Date.now() - d.getTime();
77 const diffMins = Math.floor(diffMs / 60000);
78 if (diffMins < 1) return "just now";
79 if (diffMins < 60) return `${diffMins}m ago`;
80 const diffHours = Math.floor(diffMins / 60);
81 if (diffHours < 24) return `${diffHours}h ago`;
82 const diffDays = Math.floor(diffHours / 24);
83 if (diffDays < 30) return `${diffDays}d ago`;
84 return d.toLocaleDateString();
85}
86
87// ---------- Web UI ----------
88
89notificationsRoute.get("/notifications", requireAuth, async (c) => {
90 const user = c.get("user")!;
91 const filter = c.req.query("filter") || "all"; // all | unread | mentions
92
93 let rows: Array<{
94 id: string;
95 kind: string;
96 title: string;
97 body: string | null;
98 url: string | null;
99 readAt: Date | null;
100 createdAt: Date;
101 repoName: string | null;
102 repoOwner: string | null;
103 }> = [];
10452
53 let unreadCount = 0;
10554 try {
106 const owners = users;
107 const base = db
108 .select({
109 id: notifications.id,
110 kind: notifications.kind,
111 title: notifications.title,
112 body: notifications.body,
113 url: notifications.url,
114 readAt: notifications.readAt,
115 createdAt: notifications.createdAt,
116 repoName: repositories.name,
117 repoOwner: owners.username,
118 })
55 const [result] = await db
56 .select({ count: sql<number>`count(*)` })
11957 .from(notifications)
120 .leftJoin(repositories, eq(notifications.repositoryId, repositories.id))
121 .leftJoin(owners, eq(repositories.ownerId, owners.id));
122
123 if (filter === "unread") {
124 rows = await base
125 .where(
126 and(eq(notifications.userId, user.id), isNull(notifications.readAt))
127 )
128 .orderBy(desc(notifications.createdAt))
129 .limit(100);
130 } else if (filter === "mentions") {
131 rows = await base
132 .where(
133 and(
134 eq(notifications.userId, user.id),
135 eq(notifications.kind, "mention")
136 )
137 )
138 .orderBy(desc(notifications.createdAt))
139 .limit(100);
140 } else {
141 rows = await base
142 .where(eq(notifications.userId, user.id))
143 .orderBy(desc(notifications.createdAt))
144 .limit(100);
145 }
146 } catch (err) {
147 console.error("[notifications] list:", err);
58 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
59 unreadCount = result?.count ?? 0;
60 } catch {
61 // Table may not exist yet
14862 }
14963
150 const unreadCount = rows.filter((r) => !r.readAt).length;
64 const markAllReadAction = unreadCount > 0 ? (
65 <Form action="/notifications/read-all" csrfToken={csrfToken}>
66 <Button size="sm" type="submit">Mark all read</Button>
67 </Form>
68 ) : null;
15169
15270 return c.html(
15371 <Layout title="Notifications" user={user}>
@@ -185,29 +103,30 @@ notificationsRoute.get("/notifications", requireAuth, async (c) => {
185103 <h2>Inbox zero</h2>
186104 <p>You're all caught up.</p>
187105 </div>
188 ) : (
189 <div class="notification-list">
190 {rows.map((n) => {
191 const badge = kindBadge(n.kind);
192 const unread = !n.readAt;
193 return (
194 <div class={`notification-item${unread ? " unread" : ""}`}>
195 <span
196 class="notification-badge"
197 style={`background: ${badge.color}20; color: ${badge.color}; border-color: ${badge.color}`}
198 >
199 {badge.label}
200 </span>
201 <div class="notification-body">
202 <div class="notification-title">
203 {n.url ? (
204 <a href={n.url}>{n.title}</a>
205 ) : (
206 <span>{n.title}</span>
207 )}
208 </div>
106
107 {items.length === 0 ? (
108 <EmptyState title="All caught up">
109 <p>No {filter === "unread" ? "unread " : ""}notifications.</p>
110 </EmptyState>
111 ) : (
112 <List>
113 {items.map((n: any) => (
114 <ListItem style={n.isRead ? "opacity:0.6" : ""}>
115 <div style="font-size:18px;padding-top:2px">
116 {n.type === "issue_comment" ? "\u{1F4AC}" :
117 n.type === "pr_review" ? "\u{1F50D}" :
118 n.type === "mention" ? "\u{1F4E3}" :
119 n.type === "star" ? "\u2B50" :
120 n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"}
121 </div>
122 <Flex direction="column" style="flex:1">
123 <Text size={14} weight={500}>
124 {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title}
125 </Text>
209126 {n.body && (
210 <div class="notification-desc">{n.body}</div>
127 <Text size={13} muted style="margin-top:2px">
128 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
129 </Text>
211130 )}
212131 <div class="notification-meta">
213132 {n.repoOwner && n.repoName && (
@@ -252,69 +171,53 @@ notificationsRoute.get("/notifications", requireAuth, async (c) => {
252171 );
253172});
254173
255notificationsRoute.post("/notifications/:id/read", requireAuth, async (c) => {
174// Mark single notification as read
175notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
256176 const user = c.get("user")!;
257177 const id = c.req.param("id");
178
258179 try {
259180 await db
260181 .update(notifications)
261 .set({ readAt: new Date() })
182 .set({ isRead: true })
262183 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
263 } catch (err) {
264 console.error("[notifications] mark read:", err);
184 } catch {
185 // Table may not exist
265186 }
187
266188 return c.redirect("/notifications");
267189});
268190
269notificationsRoute.post("/notifications/read-all", requireAuth, async (c) => {
191// Mark all as read
192notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
270193 const user = c.get("user")!;
194
271195 try {
272196 await db
273197 .update(notifications)
274 .set({ readAt: new Date() })
275 .where(
276 and(eq(notifications.userId, user.id), isNull(notifications.readAt))
277 );
278 } catch (err) {
279 console.error("[notifications] read-all:", err);
198 .set({ isRead: true })
199 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
200 } catch {
201 // Table may not exist
280202 }
281 return c.redirect("/notifications");
282});
283203
284notificationsRoute.post("/notifications/:id/delete", requireAuth, async (c) => {
285 const user = c.get("user")!;
286 const id = c.req.param("id");
287 try {
288 await db
289 .delete(notifications)
290 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
291 } catch (err) {
292 console.error("[notifications] delete:", err);
293 }
294 return c.redirect(c.req.header("referer") || "/notifications");
204 return c.redirect("/notifications");
295205});
296206
297// ---------- JSON API ----------
207// API: Get unread count (for bell icon polling)
208notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
209 const user = c.get("user");
210 if (!user) return c.json({ count: 0 });
298211
299notificationsRoute.get("/api/notifications/unread", requireAuth, async (c) => {
300 const user = c.get("user")!;
301 const count = await getUnreadCount(user.id);
302 return c.json({ count });
303});
304
305notificationsRoute.get("/api/notifications", requireAuth, async (c) => {
306 const user = c.get("user")!;
307212 try {
308 const rows = await db
309 .select()
213 const [result] = await db
214 .select({ count: sql<number>`count(*)` })
310215 .from(notifications)
311 .where(eq(notifications.userId, user.id))
312 .orderBy(desc(notifications.createdAt))
313 .limit(100);
314 return c.json(rows);
216 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
217 return c.json({ count: result?.count ?? 0 });
315218 } catch {
316 return c.json([]);
219 return c.json({ count: 0 });
317220 }
318221});
319222
320export default notificationsRoute;
223export default notificationRoutes;
Addedsrc/routes/onboarding.tsx+190−0View fileUnifiedSplit
@@ -0,0 +1,190 @@
1/**
2 * Onboarding flow — guided setup for new users.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import { softAuth, requireAuth } from "../middleware/auth";
8import type { AuthEnv } from "../middleware/auth";
9import { eq, sql } from "drizzle-orm";
10import { db } from "../db";
11import { repositories, sshKeys, apiTokens, users } from "../db/schema";
12import {
13 Container,
14 WelcomeHero,
15 StepIndicator,
16 Card,
17 Flex,
18 Text,
19 LinkButton,
20 CopyBlock,
21 Kbd,
22 Spacer,
23} from "../views/ui";
24
25const onboardingRoutes = new Hono<AuthEnv>();
26
27onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
28 const user = c.get("user")!;
29
30 // Check what the user has done
31 let repoCount = 0;
32 let hasKeys = false;
33 let hasTokens = false;
34
35 try {
36 const [repos] = await db
37 .select({ count: sql<number>`count(*)` })
38 .from(repositories)
39 .where(eq(repositories.ownerId, user.id));
40 repoCount = repos?.count ?? 0;
41
42 const [keys] = await db
43 .select({ count: sql<number>`count(*)` })
44 .from(sshKeys)
45 .where(eq(sshKeys.userId, user.id));
46 hasKeys = (keys?.count ?? 0) > 0;
47
48 const [tokens] = await db
49 .select({ count: sql<number>`count(*)` })
50 .from(apiTokens)
51 .where(eq(apiTokens.userId, user.id));
52 hasTokens = (tokens?.count ?? 0) > 0;
53 } catch { /* DB may not be ready */ }
54
55 const steps = [
56 { label: "Create account", completed: true, active: false },
57 { label: "Create a repository", completed: repoCount > 0, active: repoCount === 0 },
58 { label: "Push your code", completed: false, active: repoCount > 0 },
59 { label: "Set up SSH key", completed: hasKeys, active: !hasKeys && repoCount > 0 },
60 { label: "Create API token", completed: hasTokens, active: !hasTokens && hasKeys },
61 ];
62
63 const activeStep = steps.findIndex((s) => s.active);
64
65 return c.html(
66 <Layout title="Getting Started" user={user}>
67 <Container maxWidth={700}>
68 <WelcomeHero title="Welcome to gluecron" subtitle="Let's get you set up in a few steps." />
69
70 <div style="display:flex;justify-content:center;margin-bottom:40px">
71 <StepIndicator steps={steps} />
72 </div>
73
74 {/* Step 1: Create repository */}
75 {activeStep <= 1 && repoCount === 0 && (
76 <StepCard
77 number={1}
78 title="Create your first repository"
79 description="A repository contains all your project files, including the revision history."
80 active
81 >
82 <Spacer size={12} />
83 <LinkButton href="/new" variant="primary">Create repository</LinkButton>
84 </StepCard>
85 )}
86
87 {/* Step 2: Push code */}
88 {repoCount > 0 && (
89 <StepCard
90 number={2}
91 title="Push your code"
92 description="Connect your local repository and push your first commit."
93 >
94 <Spacer size={12} />
95 <CopyBlock text={`git remote add gluecron http://localhost:3000/${user.username}/your-repo.git\ngit push -u gluecron main`} label="Commands" />
96 </StepCard>
97 )}
98
99 {/* Step 3: SSH key */}
100 <StepCard
101 number={3}
102 title={hasKeys ? "SSH key added \u2713" : "Add an SSH key"}
103 description={hasKeys ? "Your SSH key is configured." : "SSH keys let you push code securely without entering your password."}
104 completed={hasKeys}
105 >
106 {!hasKeys && (
107 <>
108 <Spacer size={12} />
109 <CopyBlock text={`ssh-keygen -t ed25519 -C "your@email.com"\ncat ~/.ssh/id_ed25519.pub`} label="Generate & copy key" />
110 <Spacer size={12} />
111 <LinkButton href="/settings/keys" variant="primary" size="sm">Add SSH key</LinkButton>
112 </>
113 )}
114 </StepCard>
115
116 {/* Step 4: API token */}
117 <StepCard
118 number={4}
119 title={hasTokens ? "API token created \u2713" : "Create an API token"}
120 description={hasTokens ? "You have an API token configured." : "API tokens let you automate workflows and integrate with CI/CD."}
121 completed={hasTokens}
122 >
123 {!hasTokens && (
124 <>
125 <Spacer size={12} />
126 <Text size={14} muted>
127 Use tokens to authenticate with the gluecron API for scripting and automation.
128 </Text>
129 <Spacer size={12} />
130 <LinkButton href="/settings/tokens" variant="primary" size="sm">Create token</LinkButton>
131 </>
132 )}
133 </StepCard>
134
135 {/* All done */}
136 {repoCount > 0 && hasKeys && hasTokens && (
137 <Card style="text-align:center;padding:40px 0;border-color:var(--green);margin-top:24px;background:rgba(63,185,80,0.05)">
138 <div style="font-size:48px;margin-bottom:12px">🎉</div>
139 <h2>You're all set!</h2>
140 <Text size={14} muted style="display:block;margin-top:8px">You've completed the setup. Start building something great.</Text>
141 <Flex gap={12} justify="center" style="margin-top:20px">
142 <LinkButton href="/" variant="primary">Go to dashboard</LinkButton>
143 <LinkButton href="/api/docs">Explore the API</LinkButton>
144 <LinkButton href="/explore">Discover repos</LinkButton>
145 </Flex>
146 </Card>
147 )}
148
149 <div style="text-align:center;padding:32px 0">
150 <Text size={13} muted>
151 Need help? Check the <a href="/api/docs">API documentation</a> or press <Kbd>?</Kbd> for keyboard shortcuts.
152 </Text>
153 </div>
154 </Container>
155 </Layout>
156 );
157});
158
159const StepCard = ({
160 number,
161 title,
162 description,
163 active,
164 completed,
165 children,
166}: {
167 number: number;
168 title: string;
169 description: string;
170 active?: boolean;
171 completed?: boolean;
172 children?: any;
173}) => (
174 <Card style={`border-color:${completed ? "var(--green)" : active ? "var(--accent)" : "var(--border)"};padding:20px;margin-bottom:16px;${completed ? "opacity:0.7;" : ""}`}>
175 <Flex gap={12} align="flex-start">
176 <div
177 style={`width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;flex-shrink:0;${completed ? "background:var(--green);color:#fff" : "background:var(--bg-tertiary);color:var(--text-muted)"}`}
178 >
179 {completed ? "\u2713" : number}
180 </div>
181 <div style="flex:1">
182 <h3 style="font-size:16px;margin-bottom:4px">{title}</h3>
183 <Text size={14} muted>{description}</Text>
184 {children}
185 </div>
186 </Flex>
187 </Card>
188);
189
190export default onboardingRoutes;
Modifiedsrc/routes/orgs.tsx+339−857View fileUnifiedSplit
Large file (1,343 lines). Load full file
Modifiedsrc/routes/pulls.tsx+164−523View fileUnifiedSplit
@@ -28,18 +28,28 @@ import {
2828} from "../git/repository";
2929import type { GitDiffFile } from "../git/repository";
3030import { html } from "hono/html";
31import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
32import { triagePullRequest } from "../lib/ai-generators";
33import { mergeWithAutoResolve } from "../lib/merge-resolver";
34import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
35import { labels as labelsTable } from "../db/schema";
3631import {
37 matchProtection,
38 evaluateProtection,
39 countHumanApprovals,
40 listRequiredChecks,
41 passingCheckNames,
42} from "../lib/branch-protection";
32 Flex,
33 Container,
34 Badge,
35 Button,
36 LinkButton,
37 Form,
38 FormGroup,
39 Input,
40 TextArea,
41 Select,
42 EmptyState,
43 FilterTabs,
44 TabNav,
45 List,
46 ListItem,
47 Text,
48 Alert,
49 MarkdownContent,
50 CommentBox,
51 formatRelative,
52} from "../views/ui";
4353
4454const pulls = new Hono<AuthEnv>();
4555
@@ -71,29 +81,14 @@ const PrNav = ({
7181 repo: string;
7282 active: "code" | "issues" | "pulls" | "commits";
7383}) => (
74 <div class="repo-nav">
75 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
76 Code
77 </a>
78 <a
79 href={`/${owner}/${repo}/issues`}
80 class={active === "issues" ? "active" : ""}
81 >
82 Issues
83 </a>
84 <a
85 href={`/${owner}/${repo}/pulls`}
86 class={active === "pulls" ? "active" : ""}
87 >
88 Pull Requests
89 </a>
90 <a
91 href={`/${owner}/${repo}/commits`}
92 class={active === "commits" ? "active" : ""}
93 >
94 Commits
95 </a>
96 </div>
84 <TabNav
85 tabs={[
86 { label: "Code", href: `/${owner}/${repo}`, active: active === "code" },
87 { label: "Issues", href: `/${owner}/${repo}/issues`, active: active === "issues" },
88 { label: "Pull Requests", href: `/${owner}/${repo}/pulls`, active: active === "pulls" },
89 { label: "Commits", href: `/${owner}/${repo}/commits`, active: active === "commits" },
90 ]}
91 />
9792);
9893
9994// List PRs
@@ -140,89 +135,53 @@ pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
140135 <Layout title={`Pull Requests — ${ownerName}/${repoName}`} user={user}>
141136 <RepoHeader owner={ownerName} repo={repoName} />
142137 <PrNav owner={ownerName} repo={repoName} active="pulls" />
143 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
144 <div class="issue-tabs">
145 <a
146 href={`/${ownerName}/${repoName}/pulls?state=open`}
147 class={state === "open" ? "active" : ""}
148 >
149 {counts?.open ?? 0} Open
150 </a>
151 <a
152 href={`/${ownerName}/${repoName}/pulls?state=draft`}
153 class={state === "draft" ? "active" : ""}
154 >
155 {counts?.draft ?? 0} Draft
156 </a>
157 <a
158 href={`/${ownerName}/${repoName}/pulls?state=merged`}
159 class={state === "merged" ? "active" : ""}
160 >
161 {counts?.merged ?? 0} Merged
162 </a>
163 <a
164 href={`/${ownerName}/${repoName}/pulls?state=closed`}
165 class={state === "closed" ? "active" : ""}
166 >
167 {counts?.closed ?? 0} Closed
168 </a>
169 </div>
138 <Flex justify="space-between" align="center" style="margin-bottom:16px">
139 <FilterTabs
140 tabs={[
141 { label: `${counts?.open ?? 0} Open`, href: `/${ownerName}/${repoName}/pulls?state=open`, active: state === "open" },
142 { label: `${counts?.merged ?? 0} Merged`, href: `/${ownerName}/${repoName}/pulls?state=merged`, active: state === "merged" },
143 { label: `${counts?.closed ?? 0} Closed`, href: `/${ownerName}/${repoName}/pulls?state=closed`, active: state === "closed" },
144 ]}
145 />
170146 {user && (
171 <a
172 href={`/${ownerName}/${repoName}/pulls/new`}
173 class="btn btn-primary"
174 >
147 <LinkButton href={`/${ownerName}/${repoName}/pulls/new`} variant="primary">
175148 New pull request
176 </a>
149 </LinkButton>
177150 )}
178 </div>
151 </Flex>
179152 {prList.length === 0 ? (
180 <div class="empty-state">
153 <EmptyState>
181154 <p>No {state} pull requests.</p>
182 </div>
155 </EmptyState>
183156 ) : (
184 <div class="issue-list">
185 {prList.map(({ pr, author }) => {
186 const isDraft = pr.state === "open" && pr.isDraft;
187 const stateClass = isDraft
188 ? "state-draft"
189 : pr.state === "open"
190 ? "state-open"
191 : pr.state === "merged"
192 ? "state-merged"
193 : "state-closed";
194 const stateIcon = isDraft
195 ? "\u270E"
196 : pr.state === "open"
197 ? "\u25CB"
198 : pr.state === "merged"
199 ? "\u2B8C"
200 : "\u2713";
201 return (
202 <div class="issue-item">
203 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
204 <div>
205 <div class="issue-title">
206 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
207 {pr.title}
208 </a>
209 {isDraft && (
210 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
211 Draft
212 </span>
213 )}
214 </div>
215 <div class="issue-meta">
216 #{pr.number}{" "}
217 {pr.headBranch} → {pr.baseBranch}{" "}
218 by {author.username}{" "}
219 {formatRelative(pr.createdAt)}
220 </div>
157 <List>
158 {prList.map(({ pr, author }) => (
159 <ListItem>
160 <div
161 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
162 >
163 {pr.state === "open"
164 ? "\u25CB"
165 : pr.state === "merged"
166 ? "\u2B8C"
167 : "\u2713"}
168 </div>
169 <div>
170 <div class="issue-title">
171 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
172 {pr.title}
173 </a>
174 </div>
175 <div class="issue-meta">
176 #{pr.number}{" "}
177 {pr.headBranch} → {pr.baseBranch}{" "}
178 by {author.username}{" "}
179 {formatRelative(pr.createdAt)}
221180 </div>
222181 </div>
223 );
224 })}
225 </div>
182 </ListItem>
183 ))}
184 </List>
226185 )}
227186 </Layout>
228187 );
@@ -245,15 +204,10 @@ pulls.get(
245204 <Layout title={`New PR — ${ownerName}/${repoName}`} user={user}>
246205 <RepoHeader owner={ownerName} repo={repoName} />
247206 <PrNav owner={ownerName} repo={repoName} active="pulls" />
248 <div style="max-width: 800px">
249 <h2 style="margin-bottom: 16px">Open a pull request</h2>
250 {template && (
251 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px">
252 Using <code>PULL_REQUEST_TEMPLATE.md</code> from the default branch.
253 </div>
254 )}
207 <Container maxWidth={800}>
208 <h2 style="margin-bottom:16px">Open a pull request</h2>
255209 {error && (
256 <div class="auth-error">{decodeURIComponent(error)}</div>
210 <Alert variant="error">{decodeURIComponent(error)}</Alert>
257211 )}
258212 <form method="post" action={`/${ownerName}/${repoName}/pulls/new`}>
259213 <div style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px">
@@ -263,52 +217,38 @@ pulls.get(
263217 {b}
264218 </option>
265219 ))}
266 </select>
267 <span style="color: var(--text-muted)">←</span>
268 <select name="head" style="padding: 6px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px">
220 </Select>
221 <Text muted>←</Text>
222 <Select name="head">
269223 {branches
270224 .filter((b) => b !== defaultBase)
271225 .concat(defaultBase === branches[0] ? [] : [branches[0]])
272226 .map((b) => (
273227 <option value={b}>{b}</option>
274228 ))}
275 </select>
276 </div>
277 <div class="form-group">
278 <input
279 type="text"
229 </Select>
230 </Flex>
231 <FormGroup>
232 <Input
280233 name="title"
281234 required
282235 placeholder="Title"
283 style="font-size: 16px; padding: 10px 14px"
236 style="font-size:16px;padding:10px 14px"
284237 />
285 </div>
286 <div class="form-group">
287 <textarea
238 </FormGroup>
239 <FormGroup>
240 <TextArea
288241 name="body"
289242 rows={8}
290243 placeholder="Description (Markdown supported)"
291 style="font-family: var(--font-mono); font-size: 13px"
292 >
293 {template || ""}
294 </textarea>
295 </div>
296 <div style="display: flex; gap: 8px">
297 <button type="submit" class="btn btn-primary">
298 Create pull request
299 </button>
300 <button
301 type="submit"
302 name="draft"
303 value="1"
304 class="btn"
305 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
306 >
307 Create draft
308 </button>
309 </div>
310 </form>
311 </div>
244 mono
245 />
246 </FormGroup>
247 <Button type="submit" variant="primary">
248 Create pull request
249 </Button>
250 </Form>
251 </Container>
312252 </Layout>
313253 );
314254 }
@@ -496,72 +436,54 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
496436 <div class="issue-detail">
497437 <h2>
498438 {pr.title}{" "}
499 <span style="color: var(--text-muted); font-weight: 400">
439 <Text color="var(--text-muted)" weight={400}>
500440 #{pr.number}
501 </span>
441 </Text>
502442 </h2>
503 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
504 {pr.state === "open" && pr.isDraft ? (
505 <span class="issue-badge draft-badge">
506 {"\u270E Draft"}
507 </span>
508 ) : (
509 <span
510 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
511 >
512 {pr.state === "open"
513 ? "\u25CB Open"
514 : pr.state === "merged"
515 ? "\u2B8C Merged"
516 : "\u2713 Closed"}
517 </span>
518 )}
519 <span style="color: var(--text-muted); font-size: 14px">
520 <strong style="color: var(--text)">
443 <Flex align="center" gap={8} style="margin:8px 0 20px">
444 <Badge
445 variant={pr.state === "open" ? "open" : pr.state === "merged" ? "merged" : "closed"}
446 >
447 {pr.state === "open"
448 ? "\u25CB Open"
449 : pr.state === "merged"
450 ? "\u2B8C Merged"
451 : "\u2713 Closed"}
452 </Badge>
453 <Text size={14} muted>
454 <strong style="color:var(--text)">
521455 {author?.username}
522456 </strong>{" "}
523457 wants to merge <code>{pr.headBranch}</code> into{" "}
524458 <code>{pr.baseBranch}</code>
525 </span>
526 </div>
527
528 <div class="issue-tabs" style="margin-bottom: 20px">
529 <a
530 href={`/${ownerName}/${repoName}/pulls/${pr.number}`}
531 class={tab === "conversation" ? "active" : ""}
532 >
533 Conversation
534 </a>
535 <a
536 href={`/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`}
537 class={tab === "files" ? "active" : ""}
538 >
539 Files changed
540 </a>
541 </div>
459 </Text>
460 </Flex>
461
462 <FilterTabs
463 tabs={[
464 {
465 label: "Conversation",
466 href: `/${ownerName}/${repoName}/pulls/${pr.number}`,
467 active: tab === "conversation",
468 },
469 {
470 label: "Files changed",
471 href: `/${ownerName}/${repoName}/pulls/${pr.number}?tab=files`,
472 active: tab === "files",
473 },
474 ]}
475 />
542476
543477 {tab === "files" ? (
544478 <DiffView raw={diffRaw} files={diffFiles} />
545479 ) : (
546480 <>
547481 {pr.body && (
548 <div class="issue-comment-box">
549 <div class="comment-header">
550 <strong>{author?.username}</strong> commented{" "}
551 {formatRelative(pr.createdAt)}
552 </div>
553 <div class="markdown-body">
554 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
555 </div>
556 <div style="padding: 0 16px 12px">
557 <ReactionsBar
558 targetType="pr"
559 targetId={pr.id}
560 summaries={prReactions}
561 canReact={!!user}
562 />
563 </div>
564 </div>
482 <CommentBox
483 author={author?.username ?? "unknown"}
484 date={pr.createdAt}
485 body={renderMarkdown(pr.body)}
486 />
565487 )}
566488
567489 {comments.map(({ comment, author: commentAuthor }, i) => (
@@ -569,32 +491,25 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
569491 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
570492 >
571493 <div class="comment-header">
572 <strong>{commentAuthor.username}</strong>
573 {comment.isAiReview && (
574 <span class="badge" style="margin-left: 8px; background: rgba(31, 111, 235, 0.15); color: var(--text-link); border-color: var(--accent)">
575 AI Review
576 </span>
577 )}
578 {" "}
579 commented {formatRelative(comment.createdAt)}
580 {comment.filePath && (
581 <span style="margin-left: 8px; font-family: var(--font-mono); font-size: 11px">
582 {comment.filePath}
583 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
584 </span>
585 )}
586 </div>
587 <div class="markdown-body">
588 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
589 </div>
590 <div style="padding: 0 16px 12px">
591 <ReactionsBar
592 targetType="pr_comment"
593 targetId={comment.id}
594 summaries={prCommentReactions[i] || []}
595 canReact={!!user}
596 />
494 <Flex gap={8} align="center">
495 <strong>{commentAuthor.username}</strong>
496 {comment.isAiReview && (
497 <Badge variant="default" style="margin-left:8px;background:rgba(31,111,235,0.15);color:var(--text-link);border-color:var(--accent)">
498 AI Review
499 </Badge>
500 )}
501 <Text size={13} muted>
502 commented {formatRelative(comment.createdAt)}
503 </Text>
504 {comment.filePath && (
505 <Text size={11} mono style="margin-left:8px">
506 {comment.filePath}
507 {comment.lineNumber ? `:${comment.lineNumber}` : ""}
508 </Text>
509 )}
510 </Flex>
597511 </div>
512 <MarkdownContent html={renderMarkdown(comment.body)} />
598513 </div>
599514 ))}
600515
@@ -631,75 +546,42 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
631546 <form
632547 method="post"
633548 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comment`}
549 method="POST"
634550 >
635 <div class="form-group">
636 <textarea
551 <FormGroup>
552 <TextArea
637553 name="body"
638554 rows={6}
639555 required
640556 placeholder="Leave a comment... (Markdown supported)"
641 style="font-family: var(--font-mono); font-size: 13px"
557 mono
642558 />
643 </div>
644 <div style="display: flex; gap: 8px">
645 <button type="submit" class="btn btn-primary">
559 </FormGroup>
560 <Flex gap={8}>
561 <Button type="submit" variant="primary">
646562 Comment
647 </button>
563 </Button>
648564 {canManage && (
649565 <>
650 {pr.isDraft ? (
651 <button
652 type="submit"
653 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
654 class="btn"
655 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
656 title="Mark this draft PR as ready for review — triggers AI review"
657 >
658 Ready for review
659 </button>
660 ) : (
661 <>
662 <button
663 type="submit"
664 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
665 class="btn"
666 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
667 >
668 {gateChecks.every((c) => c.passed)
669 ? "Merge pull request"
670 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
671 ? "Merge with auto-resolve"
672 : "Merge pull request"}
673 </button>
674 <button
675 type="submit"
676 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/enqueue`}
677 class="btn"
678 title="Queue this PR — gates will re-run against latest base before merge"
679 >
680 Add to merge queue
681 </button>
682 <button
683 type="submit"
684 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
685 class="btn"
686 title="Convert back to draft"
687 >
688 Convert to draft
689 </button>
690 </>
691 )}
692566 <button
693567 type="submit"
568 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
569 class="btn"
570 style="background:rgba(63,185,80,0.15);border-color:var(--green);color:var(--green)"
571 >
572 Merge pull request
573 </button>
574 <Button
575 type="submit"
576 variant="danger"
694577 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
695 class="btn btn-danger"
696578 >
697579 Close
698 </button>
580 </Button>
699581 </>
700582 )}
701 </div>
702 </form>
583 </Flex>
584 </Form>
703585 </div>
704586 )}
705587 </>
@@ -1089,245 +971,4 @@ pulls.post(
1089971 }
1090972);
1091973
1092/**
1093 * Trigger AI code review asynchronously after PR creation.
1094 * Runs the diff through Claude and posts review comments.
1095 */
1096async function triggerAiReview(
1097 ownerName: string,
1098 repoName: string,
1099 prId: string,
1100 title: string,
1101 body: string | null,
1102 baseBranch: string,
1103 headBranch: string
1104): Promise<void> {
1105 const repoDir = getRepoPath(ownerName, repoName);
1106
1107 // Get the diff between branches
1108 const proc = Bun.spawn(
1109 ["git", "diff", `${baseBranch}...${headBranch}`],
1110 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1111 );
1112 const diffText = await new Response(proc.stdout).text();
1113 await proc.exited;
1114
1115 if (!diffText.trim()) return;
1116
1117 const result = await reviewDiff(
1118 `${ownerName}/${repoName}`,
1119 title,
1120 body,
1121 baseBranch,
1122 headBranch,
1123 diffText
1124 );
1125
1126 // We need a system user for AI reviews — use the PR author for now
1127 // Get the PR to find the author
1128 const [pr] = await db
1129 .select()
1130 .from(pullRequests)
1131 .where(eq(pullRequests.id, prId))
1132 .limit(1);
1133
1134 if (!pr) return;
1135
1136 // Post summary comment
1137 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
1138 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
1139
1140 if (result.comments.length > 0) {
1141 commentBody += "\n\n### Issues Found\n";
1142 for (const comment of result.comments) {
1143 const location = comment.filePath
1144 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
1145 : "";
1146 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
1147 }
1148 }
1149
1150 await db.insert(prComments).values({
1151 pullRequestId: prId,
1152 authorId: pr.authorId,
1153 body: commentBody,
1154 isAiReview: true,
1155 });
1156
1157 // Post individual file-level comments
1158 for (const comment of result.comments) {
1159 if (comment.filePath) {
1160 await db.insert(prComments).values({
1161 pullRequestId: prId,
1162 authorId: pr.authorId,
1163 body: comment.body,
1164 isAiReview: true,
1165 filePath: comment.filePath,
1166 lineNumber: comment.lineNumber,
1167 });
1168 }
1169 }
1170
1171 console.log(
1172 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
1173 );
1174}
1175
1176/**
1177 * D3 — AI PR triage. Runs Claude Haiku on the PR title/body + diff summary and
1178 * posts an AI-authored comment suggesting labels, reviewers, and priority.
1179 * Nothing is auto-applied — the PR author remains in control.
1180 */
1181async function triggerPrTriage(args: {
1182 ownerName: string;
1183 repoName: string;
1184 repositoryId: string;
1185 prId: string;
1186 prAuthorId: string;
1187 title: string;
1188 body: string;
1189 baseBranch: string;
1190 headBranch: string;
1191}): Promise<void> {
1192 try {
1193 // Gather candidate reviewers (top contributors from recent commits).
1194 const repoDir = getRepoPath(args.ownerName, args.repoName);
1195 const shortlogProc = Bun.spawn(
1196 ["git", "shortlog", "-sn", "--no-merges", "-50", args.baseBranch],
1197 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1198 );
1199 const shortlogOut = await new Response(shortlogProc.stdout).text();
1200 await shortlogProc.exited;
1201 const authorNames = shortlogOut
1202 .trim()
1203 .split("\n")
1204 .map((l) => l.trim().split(/\s+/).slice(1).join(" "))
1205 .filter(Boolean)
1206 .slice(0, 10);
1207
1208 // Look up usernames matching these author names (best-effort match).
1209 const candidateUsernames: string[] = [];
1210 if (authorNames.length > 0) {
1211 try {
1212 const matches = await db
1213 .select({ username: users.username, displayName: users.displayName })
1214 .from(users)
1215 .limit(100);
1216 for (const u of matches) {
1217 if (
1218 authorNames.some(
1219 (n) =>
1220 u.username === n ||
1221 (u.displayName && u.displayName === n)
1222 )
1223 ) {
1224 candidateUsernames.push(u.username);
1225 }
1226 }
1227 } catch {
1228 /* ignore */
1229 }
1230 }
1231 // Always include the repo owner as a candidate reviewer.
1232 try {
1233 const [ownerRow] = await db
1234 .select({ username: users.username })
1235 .from(users)
1236 .where(eq(users.username, args.ownerName))
1237 .limit(1);
1238 if (ownerRow && !candidateUsernames.includes(ownerRow.username)) {
1239 candidateUsernames.push(ownerRow.username);
1240 }
1241 } catch {
1242 /* ignore */
1243 }
1244
1245 // Load repo labels.
1246 const availableLabels = await db
1247 .select({ name: labelsTable.name })
1248 .from(labelsTable)
1249 .where(eq(labelsTable.repositoryId, args.repositoryId))
1250 .then((rows) => rows.map((r) => r.name))
1251 .catch(() => [] as string[]);
1252
1253 // Short diff summary (numstat only to keep prompt small).
1254 const statProc = Bun.spawn(
1255 ["git", "diff", "--numstat", `${args.baseBranch}...${args.headBranch}`],
1256 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1257 );
1258 const diffSummary = await new Response(statProc.stdout).text();
1259 await statProc.exited;
1260
1261 const result = await triagePullRequest(
1262 args.title,
1263 args.body,
1264 diffSummary,
1265 availableLabels,
1266 candidateUsernames
1267 );
1268
1269 // Skip posting if we have absolutely nothing useful to say.
1270 if (
1271 !result.summary &&
1272 result.suggestedLabels.length === 0 &&
1273 result.suggestedReviewerUsernames.length === 0
1274 ) {
1275 return;
1276 }
1277
1278 const priorityEmoji =
1279 result.priority === "critical"
1280 ? "**Critical**"
1281 : result.priority === "high"
1282 ? "**High**"
1283 : result.priority === "low"
1284 ? "**Low**"
1285 : "**Medium**";
1286 const parts: string[] = [`## AI Triage\n`];
1287 if (result.summary) parts.push(`${result.summary}\n`);
1288 parts.push(`- **Priority:** ${priorityEmoji}`);
1289 parts.push(`- **Risk area:** ${result.riskArea}`);
1290 if (result.suggestedLabels.length > 0) {
1291 parts.push(
1292 `- **Suggested labels:** ${result.suggestedLabels.map((l) => `\`${l}\``).join(", ")}`
1293 );
1294 }
1295 if (result.suggestedReviewerUsernames.length > 0) {
1296 parts.push(
1297 `- **Suggested reviewers:** ${result.suggestedReviewerUsernames.map((u) => `@${u}`).join(", ")}`
1298 );
1299 }
1300 parts.push(
1301 `\n_Suggestions only — nothing was auto-applied. The PR author remains in control._`
1302 );
1303
1304 await db.insert(prComments).values({
1305 pullRequestId: args.prId,
1306 authorId: args.prAuthorId,
1307 body: parts.join("\n"),
1308 isAiReview: true,
1309 });
1310 } catch (err) {
1311 console.error("[pr-triage]", err);
1312 }
1313}
1314
1315function formatRelative(date: Date | string): string {
1316 const d = typeof date === "string" ? new Date(date) : date;
1317 const now = new Date();
1318 const diffMs = now.getTime() - d.getTime();
1319 const diffMins = Math.floor(diffMs / 60000);
1320 if (diffMins < 1) return "just now";
1321 if (diffMins < 60) return `${diffMins}m ago`;
1322 const diffHours = Math.floor(diffMins / 60);
1323 if (diffHours < 24) return `${diffHours}h ago`;
1324 const diffDays = Math.floor(diffHours / 24);
1325 if (diffDays < 30) return `${diffDays}d ago`;
1326 return d.toLocaleDateString("en-US", {
1327 month: "short",
1328 day: "numeric",
1329 year: "numeric",
1330 });
1331}
1332
1333974export default pulls;
Modifiedsrc/routes/repo-settings.tsx+35−28View fileUnifiedSplit
@@ -12,6 +12,17 @@ import { softAuth, requireAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
1313import { listBranches } from "../git/repository";
1414import { rm } from "fs/promises";
15import {
16 Container,
17 Form,
18 FormGroup,
19 Input,
20 Select,
21 Button,
22 Alert,
23 EmptyState,
24 Text,
25} from "../views/ui";
1526
1627const repoSettings = new Hono<AuthEnv>();
1728
@@ -33,10 +44,9 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
3344 if (!owner || owner.id !== user.id) {
3445 return c.html(
3546 <Layout title="Unauthorized" user={user}>
36 <div class="empty-state">
37 <h2>Unauthorized</h2>
47 <EmptyState title="Unauthorized">
3848 <p>Only the repository owner can access settings.</p>
39 </div>
49 </EmptyState>
4050 </Layout>,
4151 403
4252 );
@@ -57,32 +67,30 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
5767 return c.html(
5868 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
5969 <RepoHeader owner={ownerName} repo={repoName} />
60 <div style="max-width: 600px">
70 <Container maxWidth={600}>
6171 <h2 style="margin-bottom: 20px">Repository settings</h2>
6272 {success && (
63 <div class="auth-success">{decodeURIComponent(success)}</div>
73 <Alert variant="success">{decodeURIComponent(success)}</Alert>
6474 )}
6575 {error && (
66 <div class="auth-error">{decodeURIComponent(error)}</div>
76 <Alert variant="error">{decodeURIComponent(error)}</Alert>
6777 )}
6878
6979 <form
7080 method="post"
7181 action={`/${ownerName}/${repoName}/settings`}
82 method="POST"
7283 >
73 <div class="form-group">
74 <label for="description">Description</label>
75 <input
76 type="text"
77 id="description"
84 <FormGroup label="Description" htmlFor="description">
85 <Input
7886 name="description"
87 id="description"
7988 value={repo.description || ""}
8089 placeholder="A short description"
8190 />
82 </div>
83 <div class="form-group">
84 <label for="default_branch">Default branch</label>
85 <select id="default_branch" name="default_branch">
91 </FormGroup>
92 <FormGroup label="Default branch" htmlFor="default_branch">
93 <Select name="default_branch" id="default_branch" value={repo.defaultBranch}>
8694 {branches.length === 0 ? (
8795 <option value={repo.defaultBranch}>
8896 {repo.defaultBranch}
@@ -94,10 +102,9 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
94102 </option>
95103 ))
96104 )}
97 </select>
98 </div>
99 <div class="form-group">
100 <label>Visibility</label>
105 </Select>
106 </FormGroup>
107 <FormGroup label="Visibility">
101108 <div class="visibility-options">
102109 <label class="visibility-option">
103110 <input
@@ -118,11 +125,11 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
118125 <div class="vis-label">Private</div>
119126 </label>
120127 </div>
121 </div>
122 <button type="submit" class="btn btn-primary">
128 </FormGroup>
129 <Button type="submit" variant="primary">
123130 Save changes
124 </button>
125 </form>
131 </Button>
132 </Form>
126133
127134 <div
128135 style="margin-top: 32px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
@@ -206,20 +213,20 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, async (c) => {
206213 style="margin-top: 20px; padding: 20px; border: 1px solid var(--red); border-radius: var(--radius)"
207214 >
208215 <h3 style="color: var(--red); margin-bottom: 12px">Danger zone</h3>
209 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 12px">
216 <Text size={14} muted style="display:block;margin-bottom:12px">
210217 Permanently delete this repository and all its data.
211 </p>
218 </Text>
212219 <form
213220 method="post"
214221 action={`/${ownerName}/${repoName}/settings/delete`}
215222 onsubmit="return confirm('Are you sure? This cannot be undone.')"
216223 >
217 <button type="submit" class="btn btn-danger">
224 <Button type="submit" variant="danger">
218225 Delete this repository
219 </button>
226 </Button>
220227 </form>
221228 </div>
222 </div>
229 </Container>
223230 </Layout>
224231 );
225232});
Modifiedsrc/routes/settings.tsx+39−35View fileUnifiedSplit
@@ -9,8 +9,18 @@ import { users, sshKeys } from "../db/schema";
99import type { AuthEnv } from "../middleware/auth";
1010import { requireAuth } from "../middleware/auth";
1111import { Layout } from "../views/layout";
12import { composeDigest } from "../lib/email-digest";
13import { raw } from "hono/html";
12import {
13 Alert,
14 Button,
15 Flex,
16 Form,
17 FormGroup,
18 Input,
19 PageHeader,
20 Section,
21 Text,
22 TextArea,
23} from "../views/ui";
1424
1525const settings = new Hono<AuthEnv>();
1626
@@ -26,11 +36,11 @@ settings.get("/settings", (c) => {
2636 return c.html(
2737 <Layout title="Settings">
2838 <div class="settings-container">
29 <h2>Profile settings</h2>
39 <PageHeader title="Profile settings" />
3040 {success && (
31 <div class="auth-success">
41 <Alert variant="success">
3242 {decodeURIComponent(success)}
33 </div>
43 </Alert>
3444 )}
3545 <form method="post" action="/settings/profile">
3646 <div class="form-group">
@@ -40,41 +50,35 @@ settings.get("/settings", (c) => {
4050 id="username"
4151 value={user.username}
4252 disabled
43 class="input-disabled"
4453 />
45 </div>
46 <div class="form-group">
47 <label for="display_name">Display name</label>
48 <input
49 type="text"
50 id="display_name"
54 </FormGroup>
55 <FormGroup label="Display name" htmlFor="display_name">
56 <Input
5157 name="display_name"
58 id="display_name"
5259 value={user.displayName || ""}
5360 placeholder="Your display name"
5461 />
55 </div>
56 <div class="form-group">
57 <label for="bio">Bio</label>
58 <textarea
59 id="bio"
62 </FormGroup>
63 <FormGroup label="Bio" htmlFor="bio">
64 <TextArea
6065 name="bio"
66 id="bio"
6167 rows={3}
6268 placeholder="Tell us about yourself"
63 >
64 {user.bio || ""}
65 </textarea>
66 </div>
67 <div class="form-group">
68 <label for="email">Email</label>
69 <input
70 type="email"
71 id="email"
69 value={user.bio || ""}
70 />
71 </FormGroup>
72 <FormGroup label="Email" htmlFor="email">
73 <Input
7274 name="email"
75 id="email"
76 type="email"
7377 value={user.email}
7478 required
7579 />
76 </div>
77 <button type="submit" class="btn btn-primary">
80 </FormGroup>
81 <Button type="submit" variant="primary">
7882 Update profile
7983 </button>
8084 </form>
@@ -231,18 +235,18 @@ settings.get("/settings/keys", async (c) => {
231235 return c.html(
232236 <Layout title="SSH Keys">
233237 <div class="settings-container">
234 <h2>SSH Keys</h2>
238 <PageHeader title="SSH Keys" />
235239 {success && (
236 <div class="auth-success">{decodeURIComponent(success)}</div>
240 <Alert variant="success">{decodeURIComponent(success)}</Alert>
237241 )}
238242 {error && (
239 <div class="auth-error">{decodeURIComponent(error)}</div>
243 <Alert variant="error">{decodeURIComponent(error)}</Alert>
240244 )}
241245 <div class="ssh-keys-list">
242246 {keys.length === 0 ? (
243 <p style="color: var(--text-muted)">
247 <Text muted>
244248 No SSH keys yet. Add one below.
245 </p>
249 </Text>
246250 ) : (
247251 keys.map((key) => (
248252 <div class="ssh-key-item">
@@ -269,8 +273,8 @@ settings.get("/settings/keys", async (c) => {
269273 <form method="post" action={`/settings/keys/${key.id}/delete`}>
270274 <button type="submit" class="btn btn-danger btn-sm">
271275 Delete
272 </button>
273 </form>
276 </Button>
277 </Form>
274278 </div>
275279 ))
276280 )}
Modifiedsrc/routes/tokens.tsx+35−18View fileUnifiedSplit
@@ -9,6 +9,21 @@ import { apiTokens } from "../db/schema";
99import { Layout } from "../views/layout";
1010import { softAuth, requireAuth } from "../middleware/auth";
1111import type { AuthEnv } from "../middleware/auth";
12import {
13 Container,
14 PageHeader,
15 Section,
16 Alert,
17 EmptyState,
18 ListItem,
19 Flex,
20 Form,
21 FormGroup,
22 Input,
23 Button,
24 InlineCode,
25 Text,
26} from "../views/ui";
1227
1328const tokens = new Hono<AuthEnv>();
1429
@@ -46,32 +61,33 @@ tokens.get("/settings/tokens", async (c) => {
4661
4762 return c.html(
4863 <Layout title="API Tokens" user={user}>
49 <div class="settings-container">
50 <h2>Personal access tokens</h2>
64 <Container class="settings-container">
65 <PageHeader title="Personal access tokens" />
5166 {success && (
52 <div class="auth-success" style="margin-top: 12px">
67 <Alert variant="success">
5368 {decodeURIComponent(success)}
54 </div>
69 </Alert>
5570 )}
5671 {newToken && (
57 <div
58 class="auth-success"
59 style="margin-top: 12px; font-family: var(--font-mono); word-break: break-all"
60 >
61 New token (copy now — it won't be shown again):{" "}
62 <strong>{decodeURIComponent(newToken)}</strong>
63 </div>
72 <Alert variant="success">
73 <span style="font-family: var(--font-mono); word-break: break-all">
74 New token (copy now — it won't be shown again):{" "}
75 <strong>{decodeURIComponent(newToken)}</strong>
76 </span>
77 </Alert>
6478 )}
6579 <div style="margin-top: 16px">
6680 {userTokens.length === 0 ? (
67 <p style="color: var(--text-muted)">No tokens yet.</p>
81 <EmptyState>
82 <Text muted>No tokens yet.</Text>
83 </EmptyState>
6884 ) : (
6985 userTokens.map((token) => (
70 <div class="ssh-key-item">
86 <ListItem>
7187 <div>
7288 <strong>{token.name}</strong>
7389 <div class="ssh-key-meta">
74 <code>{token.tokenPrefix}...</code>
90 <InlineCode>{token.tokenPrefix}...</InlineCode>
7591 <span style="margin-left: 8px">
7692 Scopes: {token.scopes}
7793 </span>
@@ -87,12 +103,13 @@ tokens.get("/settings/tokens", async (c) => {
87103 <form
88104 method="post"
89105 action={`/settings/tokens/${token.id}/delete`}
106 method="POST"
90107 >
91 <button type="submit" class="btn btn-danger btn-sm">
108 <Button type="submit" variant="danger" size="sm">
92109 Revoke
93 </button>
94 </form>
95 </div>
110 </Button>
111 </Form>
112 </ListItem>
96113 ))
97114 )}
98115 </div>
Modifiedsrc/routes/webhooks.tsx+29−23View fileUnifiedSplit
@@ -10,6 +10,15 @@ import { Layout } from "../views/layout";
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
1212import type { AuthEnv } from "../middleware/auth";
13import {
14 Container,
15 Flex,
16 Form,
17 FormGroup,
18 Input,
19 Button,
20 Alert,
21} from "../views/ui";
1322
1423const webhookRoutes = new Hono<AuthEnv>();
1524
@@ -54,13 +63,13 @@ webhookRoutes.get(
5463 return c.html(
5564 <Layout title={`Webhooks — ${ownerName}/${repoName}`} user={user}>
5665 <RepoHeader owner={ownerName} repo={repoName} />
57 <div style="max-width: 700px">
66 <Container maxWidth={700}>
5867 <h2 style="margin-bottom: 16px">Webhooks</h2>
5968 {success && (
60 <div class="auth-success">{decodeURIComponent(success)}</div>
69 <Alert variant="success">{decodeURIComponent(success)}</Alert>
6170 )}
6271 {error && (
63 <div class="auth-error">{decodeURIComponent(error)}</div>
72 <Alert variant="error">{decodeURIComponent(error)}</Alert>
6473 )}
6574 {hooks.length > 0 && (
6675 <div style="margin-bottom: 24px">
@@ -87,10 +96,10 @@ webhookRoutes.get(
8796 method="post"
8897 action={`/${ownerName}/${repoName}/settings/webhooks/${hook.id}/delete`}
8998 >
90 <button type="submit" class="btn btn-danger btn-sm">
99 <Button type="submit" variant="danger" size="sm">
91100 Delete
92 </button>
93 </form>
101 </Button>
102 </Form>
94103 </div>
95104 ))}
96105 </div>
@@ -101,26 +110,23 @@ webhookRoutes.get(
101110 method="post"
102111 action={`/${ownerName}/${repoName}/settings/webhooks`}
103112 >
104 <div class="form-group">
105 <label>Payload URL</label>
106 <input
113 <FormGroup label="Payload URL">
114 <Input
107115 type="url"
108116 name="url"
109117 required
110118 placeholder="https://example.com/hooks/gluecron"
111119 />
112 </div>
113 <div class="form-group">
114 <label>Secret (optional)</label>
115 <input
120 </FormGroup>
121 <FormGroup label="Secret (optional)">
122 <Input
116123 type="text"
117124 name="secret"
118125 placeholder="Shared secret for HMAC verification"
119126 />
120 </div>
121 <div class="form-group">
122 <label>Events</label>
123 <div style="display: flex; gap: 16px; flex-wrap: wrap">
127 </FormGroup>
128 <FormGroup label="Events">
129 <Flex gap={16} wrap>
124130 {["push", "issue", "pr", "star"].map((evt) => (
125131 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
126132 <input
@@ -132,13 +138,13 @@ webhookRoutes.get(
132138 {evt}
133139 </label>
134140 ))}
135 </div>
136 </div>
137 <button type="submit" class="btn btn-primary">
141 </Flex>
142 </FormGroup>
143 <Button type="submit" variant="primary">
138144 Add webhook
139 </button>
140 </form>
141 </div>
145 </Button>
146 </Form>
147 </Container>
142148 </Layout>
143149 );
144150 }
Addedsrc/views/client-js.ts+276−0View fileUnifiedSplit
@@ -0,0 +1,276 @@
1/**
2 * Client-side JavaScript — embedded inline in the layout.
3 *
4 * Provides interactivity without requiring a build step or framework:
5 * - Copy to clipboard
6 * - Markdown preview in comment forms
7 * - Keyboard shortcuts
8 * - Toast notifications
9 * - Async form submission for stars/actions
10 * - Live search debouncing
11 * - Tab indentation in textareas
12 * - Mobile hamburger menu
13 */
14
15export const clientJs = `
16(function() {
17 'use strict';
18
19 // ─── Toast Notification System ──────────────────────────────────────────
20
21 const toastContainer = document.createElement('div');
22 toastContainer.id = 'toast-container';
23 document.body.appendChild(toastContainer);
24
25 function toast(message, type) {
26 type = type || 'info';
27 var el = document.createElement('div');
28 el.className = 'toast toast-' + type;
29 el.textContent = message;
30 toastContainer.appendChild(el);
31 requestAnimationFrame(function() { el.classList.add('toast-visible'); });
32 setTimeout(function() {
33 el.classList.remove('toast-visible');
34 setTimeout(function() { el.remove(); }, 300);
35 }, 3000);
36 }
37
38 // ─── Copy to Clipboard ─────────────────────────────────────────────────
39
40 document.addEventListener('click', function(e) {
41 var btn = e.target.closest('[data-clipboard]');
42 if (!btn) return;
43 e.preventDefault();
44 var text = btn.getAttribute('data-clipboard');
45 if (navigator.clipboard) {
46 navigator.clipboard.writeText(text).then(function() {
47 var original = btn.textContent;
48 btn.textContent = 'Copied!';
49 btn.classList.add('btn-success');
50 setTimeout(function() {
51 btn.textContent = original;
52 btn.classList.remove('btn-success');
53 }, 2000);
54 });
55 }
56 });
57
58 // ─── Markdown Preview ──────────────────────────────────────────────────
59
60 document.addEventListener('click', function(e) {
61 var tab = e.target.closest('[data-tab]');
62 if (!tab) return;
63
64 var editor = tab.closest('.comment-editor');
65 if (!editor) return;
66
67 var tabName = tab.getAttribute('data-tab');
68 var tabs = editor.querySelectorAll('[data-tab]');
69 var textarea = editor.querySelector('textarea');
70 var preview = editor.querySelector('.editor-preview');
71
72 tabs.forEach(function(t) { t.classList.toggle('active', t.getAttribute('data-tab') === tabName); });
73
74 if (tabName === 'preview') {
75 textarea.style.display = 'none';
76 preview.style.display = 'block';
77 preview.innerHTML = '<div style="padding:12px;color:var(--text-muted)">Loading preview...</div>';
78
79 // Simple markdown rendering (bold, italic, code, links, headers)
80 var md = textarea.value || 'Nothing to preview';
81 var html = md
82 .replace(/&/g, '&')
83 .replace(/</g, '<')
84 .replace(/>/g, '>')
85 .replace(/^### (.+)$/gm, '<h3>$1</h3>')
86 .replace(/^## (.+)$/gm, '<h2>$1</h2>')
87 .replace(/^# (.+)$/gm, '<h1>$1</h1>')
88 .replace(/\\.([^\\x60]+)\\.\\./g, '<code>$1</code>')
89 .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
90 .replace(/\\*(.+?)\\*/g, '<em>$1</em>')
91 .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2">$1</a>')
92 .replace(/\\n/g, '<br>');
93 preview.innerHTML = '<div class="markdown-body" style="padding:12px">' + html + '</div>';
94 } else {
95 textarea.style.display = '';
96 preview.style.display = 'none';
97 }
98 });
99
100 // ─── Tab Key in Textareas ──────────────────────────────────────────────
101
102 document.addEventListener('keydown', function(e) {
103 if (e.key !== 'Tab') return;
104 var textarea = e.target;
105 if (textarea.tagName !== 'TEXTAREA') return;
106
107 e.preventDefault();
108 var start = textarea.selectionStart;
109 var end = textarea.selectionEnd;
110 var value = textarea.value;
111 textarea.value = value.substring(0, start) + ' ' + value.substring(end);
112 textarea.selectionStart = textarea.selectionEnd = start + 2;
113 });
114
115 // ─── Keyboard Shortcuts ────────────────────────────────────────────────
116
117 var shortcutOverlay = null;
118
119 function toggleShortcuts() {
120 if (shortcutOverlay) {
121 shortcutOverlay.remove();
122 shortcutOverlay = null;
123 return;
124 }
125 shortcutOverlay = document.createElement('div');
126 shortcutOverlay.className = 'shortcut-overlay';
127 shortcutOverlay.innerHTML = [
128 '<div class="shortcut-modal">',
129 '<h3>Keyboard Shortcuts</h3>',
130 '<div class="shortcut-grid">',
131 '<div><kbd>?</kbd> Show shortcuts</div>',
132 '<div><kbd>/</kbd> Focus search</div>',
133 '<div><kbd>g</kbd> <kbd>h</kbd> Go home</div>',
134 '<div><kbd>g</kbd> <kbd>e</kbd> Go to explore</div>',
135 '<div><kbd>g</kbd> <kbd>n</kbd> New repository</div>',
136 '<div><kbd>g</kbd> <kbd>s</kbd> Go to settings</div>',
137 '<div><kbd>Esc</kbd> Close modal</div>',
138 '</div>',
139 '<button class="btn btn-sm" onclick="this.closest(\\'.shortcut-overlay\\').remove()">Close</button>',
140 '</div>'
141 ].join('');
142 document.body.appendChild(shortcutOverlay);
143 shortcutOverlay.addEventListener('click', function(e) {
144 if (e.target === shortcutOverlay) {
145 shortcutOverlay.remove();
146 shortcutOverlay = null;
147 }
148 });
149 }
150
151 var gPressed = false;
152 var gTimeout;
153
154 document.addEventListener('keydown', function(e) {
155 // Don't trigger shortcuts when typing in inputs
156 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
157
158 if (e.key === '?') {
159 e.preventDefault();
160 toggleShortcuts();
161 return;
162 }
163
164 if (e.key === 'Escape') {
165 if (shortcutOverlay) {
166 shortcutOverlay.remove();
167 shortcutOverlay = null;
168 }
169 return;
170 }
171
172 if (e.key === '/') {
173 var searchInput = document.querySelector('.search-input') || document.querySelector('input[name="q"]');
174 if (searchInput) {
175 e.preventDefault();
176 searchInput.focus();
177 }
178 return;
179 }
180
181 if (e.key === 'g') {
182 if (!gPressed) {
183 gPressed = true;
184 gTimeout = setTimeout(function() { gPressed = false; }, 500);
185 return;
186 }
187 }
188
189 if (gPressed) {
190 gPressed = false;
191 clearTimeout(gTimeout);
192 if (e.key === 'h') { window.location.href = '/'; return; }
193 if (e.key === 'e') { window.location.href = '/explore'; return; }
194 if (e.key === 'n') { window.location.href = '/new'; return; }
195 if (e.key === 's') { window.location.href = '/settings'; return; }
196 }
197 });
198
199 // ─── Star Button Async ─────────────────────────────────────────────────
200
201 document.addEventListener('click', function(e) {
202 var starBtn = e.target.closest('.star-btn[type="submit"]');
203 if (!starBtn) return;
204
205 var form = starBtn.closest('form');
206 if (!form) return;
207
208 e.preventDefault();
209 var action = form.getAttribute('action');
210
211 fetch(action, {
212 method: 'POST',
213 credentials: 'same-origin',
214 headers: { 'X-Requested-With': 'XMLHttpRequest' }
215 }).then(function() {
216 // Toggle visual state
217 starBtn.classList.toggle('starred');
218 var currentText = starBtn.textContent.trim();
219 var match = currentText.match(/(\\d+)/);
220 if (match) {
221 var count = parseInt(match[1]);
222 var newCount = starBtn.classList.contains('starred') ? count + 1 : count - 1;
223 starBtn.textContent = (starBtn.classList.contains('starred') ? '\\u2605 ' : '\\u2606 ') + Math.max(0, newCount);
224 }
225 toast(starBtn.classList.contains('starred') ? 'Starred!' : 'Unstarred', 'success');
226 }).catch(function() {
227 // Fall back to normal form submission
228 form.submit();
229 });
230 });
231
232 // ─── Mobile Hamburger Menu ─────────────────────────────────────────────
233
234 var navRight = document.querySelector('.nav-right');
235 if (navRight && window.innerWidth < 768) {
236 var hamburger = document.createElement('button');
237 hamburger.className = 'hamburger-btn';
238 hamburger.innerHTML = '\\u2630';
239 hamburger.setAttribute('aria-label', 'Toggle menu');
240 navRight.parentElement.insertBefore(hamburger, navRight);
241 navRight.classList.add('mobile-hidden');
242
243 hamburger.addEventListener('click', function() {
244 navRight.classList.toggle('mobile-hidden');
245 navRight.classList.toggle('mobile-visible');
246 });
247 }
248
249 // ─── Relative Time Auto-Update ─────────────────────────────────────────
250
251 function updateTimes() {
252 document.querySelectorAll('[data-time]').forEach(function(el) {
253 var date = new Date(el.getAttribute('data-time'));
254 var now = new Date();
255 var diff = Math.floor((now - date) / 60000);
256 if (diff < 1) el.textContent = 'just now';
257 else if (diff < 60) el.textContent = diff + 'm ago';
258 else if (diff < 1440) el.textContent = Math.floor(diff / 60) + 'h ago';
259 else if (diff < 43200) el.textContent = Math.floor(diff / 1440) + 'd ago';
260 });
261 }
262 setInterval(updateTimes, 60000);
263
264 // ─── Confirmation on Dangerous Actions ─────────────────────────────────
265
266 document.addEventListener('submit', function(e) {
267 var form = e.target;
268 if (!form.classList.contains('confirm-action')) return;
269 var msg = form.getAttribute('data-confirm') || 'Are you sure?';
270 if (!confirm(msg)) {
271 e.preventDefault();
272 }
273 });
274
275})();
276`;
Modifiedsrc/views/layout.tsx+297−305View fileUnifiedSplit
@@ -1,6 +1,7 @@
11import type { FC, PropsWithChildren } from "hono/jsx";
22import type { User } from "../db/schema";
33import { hljsThemeCss } from "../lib/highlight";
4import { clientJs } from "./client-js";
45
56export const Layout: FC<
67 PropsWithChildren<{
@@ -100,34 +101,14 @@ export const Layout: FC<
100101 </div>
101102 </nav>
102103 </header>
103 <main>{children}</main>
104 <main id="main-content">{children}</main>
104105 <footer>
105106 <span>gluecron — AI-native code intelligence</span>
107 <span style="margin-left:16px">
108 <a href="/api/docs" style="color:var(--text-muted);font-size:12px">API Docs</a>
109 </span>
106110 </footer>
107 <div
108 id="cmdk-backdrop"
109 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9998;backdrop-filter:blur(2px)"
110 />
111 <div
112 id="cmdk-panel"
113 role="dialog"
114 aria-label="Command palette"
115 style="display:none;position:fixed;top:15%;left:50%;transform:translateX(-50%);width:min(560px,90vw);background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;box-shadow:0 12px 40px rgba(0,0,0,0.5);z-index:9999;overflow:hidden"
116 >
117 <input
118 id="cmdk-input"
119 type="text"
120 placeholder="Jump to... (↑↓ navigate, Enter go, Esc close)"
121 autocomplete="off"
122 style="width:100%;box-sizing:border-box;padding:14px 16px;background:transparent;border:none;border-bottom:1px solid var(--border);font-size:15px;color:var(--text);outline:none"
123 />
124 <div
125 id="cmdk-list"
126 style="max-height:360px;overflow-y:auto;font-size:13px"
127 />
128 </div>
129 <script>{navScript}</script>
130 <script>{pwaRegisterScript}</script>
111 <script>{clientJs}</script>
131112 </body>
132113 </html>
133114 );
@@ -824,351 +805,362 @@ const css = `
824805
825806 /* Search */
826807 .search-results .diff-file { margin-bottom: 12px; }
827
828 /* Nav — search + ask + notifications badge */
829 .nav-search { flex: 1; max-width: 320px; margin: 0 16px; }
830 .nav-search form { width: 100%; }
831 .nav-search input {
832 width: 100%;
833 padding: 6px 10px;
808 .search-input {
809 flex: 1;
810 padding: 8px 12px;
834811 background: var(--bg);
835812 border: 1px solid var(--border);
836813 border-radius: var(--radius);
837814 color: var(--text);
838 font-size: 13px;
815 font-size: 14px;
839816 }
840 .nav-search input::placeholder { color: var(--text-muted); }
841 .nav-search input:focus { outline: none; border-color: var(--accent); }
842 .nav-notifications { position: relative; display: inline-flex; align-items: center; }
843 .nav-badge {
844 position: absolute;
845 top: -6px;
846 right: -10px;
847 min-width: 18px;
848 height: 18px;
849 padding: 0 5px;
850 border-radius: 9px;
851 background: var(--red);
852 color: #fff;
853 font-size: 11px;
854 font-weight: 700;
855 display: flex;
856 align-items: center;
857 justify-content: center;
817 .search-input:focus {
818 outline: none;
819 border-color: var(--accent);
820 box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.3);
858821 }
859822
860 /* Notifications list */
861 .notification-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
862 .notification-item {
823 /* Toast Notifications */
824 #toast-container {
825 position: fixed;
826 top: 16px;
827 right: 16px;
828 z-index: 9999;
863829 display: flex;
864 gap: 12px;
865 align-items: flex-start;
866 padding: 12px 16px;
867 border-bottom: 1px solid var(--border);
868 background: var(--bg);
830 flex-direction: column;
831 gap: 8px;
869832 }
870 .notification-item.unread { background: rgba(56, 139, 253, 0.06); }
871 .notification-item:last-child { border-bottom: none; }
872 .notification-badge {
873 flex-shrink: 0;
874 padding: 2px 8px;
875 border-radius: 10px;
876 font-size: 11px;
877 font-weight: 600;
878 border: 1px solid;
879 text-transform: lowercase;
880 }
881 .notification-body { flex: 1; min-width: 0; }
882 .notification-title { font-size: 14px; font-weight: 500; margin-bottom: 2px; }
883 .notification-title a { color: var(--text); }
884 .notification-title a:hover { color: var(--text-link); }
885 .notification-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
886 .notification-meta { font-size: 12px; color: var(--text-muted); }
887 .notification-meta a { color: var(--text-link); }
888 .notification-actions { display: flex; gap: 4px; align-items: center; }
889 .notification-actions .btn { padding: 2px 8px; font-size: 12px; line-height: 1; }
890
891 /* Dashboard */
892 .dashboard-grid {
893 display: grid;
894 grid-template-columns: 2fr 1fr;
895 gap: 24px;
896 }
897 .dashboard-section { margin-bottom: 24px; }
898 .dashboard-section h3 {
833 .toast {
834 padding: 10px 16px;
835 border-radius: var(--radius);
899836 font-size: 14px;
900 text-transform: uppercase;
901 letter-spacing: 0.5px;
837 font-weight: 500;
838 opacity: 0;
839 transform: translateX(100%);
840 transition: all 0.3s ease;
841 min-width: 200px;
842 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
843 }
844 .toast-visible { opacity: 1; transform: translateX(0); }
845 .toast-info { background: var(--accent); color: #fff; }
846 .toast-success { background: var(--green); color: #fff; }
847 .toast-error { background: var(--red); color: #fff; }
848 .toast-warning { background: var(--yellow); color: #000; }
849
850 /* Keyboard Shortcut Hints */
851 .kbd {
852 display: inline-block;
853 padding: 2px 6px;
854 border: 1px solid var(--border);
855 border-radius: 4px;
856 background: var(--bg-secondary);
857 font-family: var(--font-mono);
858 font-size: 12px;
859 line-height: 1.4;
902860 color: var(--text-muted);
903 margin-bottom: 12px;
861 box-shadow: 0 1px 0 var(--border);
862 }
863 .shortcut-overlay {
864 position: fixed;
865 inset: 0;
866 background: rgba(0, 0, 0, 0.6);
867 z-index: 9998;
904868 display: flex;
905 justify-content: space-between;
906869 align-items: center;
870 justify-content: center;
907871 }
908 .dashboard-section h3 a { font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; }
909 .panel {
872 .shortcut-modal {
873 background: var(--bg-secondary);
910874 border: 1px solid var(--border);
911875 border-radius: var(--radius);
912 background: var(--bg-secondary);
913 overflow: hidden;
876 padding: 24px;
877 min-width: 300px;
878 max-width: 480px;
914879 }
915 .panel-item {
880 .shortcut-modal h3 { margin-bottom: 16px; }
881 .shortcut-grid {
916882 display: flex;
917 align-items: flex-start;
918 gap: 10px;
919 padding: 10px 14px;
920 border-bottom: 1px solid var(--border);
921 font-size: 13px;
883 flex-direction: column;
884 gap: 8px;
885 margin-bottom: 16px;
886 font-size: 14px;
922887 }
923 .panel-item:last-child { border-bottom: none; }
924 .panel-item .dot {
925 width: 8px; height: 8px; border-radius: 50%;
926 margin-top: 6px; flex-shrink: 0; background: var(--text-muted);
927 }
928 .panel-item .dot.green { background: var(--green); }
929 .panel-item .dot.red { background: var(--red); }
930 .panel-item .dot.yellow { background: var(--yellow); }
931 .panel-item .dot.blue { background: var(--accent); }
932 .panel-item .meta { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
933 .panel-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; }
934
935 /* AI Ask chat */
936 .ask-container { max-width: 900px; margin: 0 auto; }
937 .chat-log {
888 .shortcut-grid div {
889 display: flex;
890 align-items: center;
891 gap: 8px;
892 }
893
894 /* Comment Editor with Preview */
895 .comment-editor {
938896 border: 1px solid var(--border);
939897 border-radius: var(--radius);
940 background: var(--bg-secondary);
941 padding: 16px;
942 margin-bottom: 16px;
943 min-height: 200px;
944 max-height: 60vh;
945 overflow-y: auto;
898 overflow: hidden;
946899 }
947 .chat-message {
948 padding: 10px 14px;
949 border-radius: var(--radius);
950 margin-bottom: 10px;
951 white-space: pre-wrap;
952 word-wrap: break-word;
953 font-size: 14px;
954 line-height: 1.5;
900 .editor-tabs {
901 display: flex;
902 border-bottom: 1px solid var(--border);
903 background: var(--bg-secondary);
955904 }
956 .chat-message.user { background: var(--bg-tertiary); border: 1px solid var(--border); }
957 .chat-message.assistant { background: rgba(188, 140, 255, 0.08); border: 1px solid rgba(188, 140, 255, 0.3); }
958 .chat-message .role {
959 font-size: 11px;
960 font-weight: 600;
961 text-transform: uppercase;
962 letter-spacing: 0.5px;
905 .editor-tab {
906 padding: 6px 16px;
907 font-size: 13px;
908 background: none;
909 border: none;
963910 color: var(--text-muted);
964 margin-bottom: 6px;
911 cursor: pointer;
912 border-bottom: 2px solid transparent;
965913 }
966 .chat-form textarea {
914 .editor-tab:hover { color: var(--text); }
915 .editor-tab.active { color: var(--text); border-bottom-color: var(--accent); }
916 .comment-editor textarea {
967917 width: 100%;
968 min-height: 80px;
969 padding: 10px 12px;
918 border: none;
919 padding: 12px;
970920 background: var(--bg);
971 border: 1px solid var(--border);
972 border-radius: var(--radius);
973921 color: var(--text);
974 font-family: var(--font-sans);
975 font-size: 14px;
922 font-family: var(--font-mono);
923 font-size: 13px;
976924 resize: vertical;
925 min-height: 120px;
977926 }
978 .chat-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
979 .chat-cited {
980 display: inline-block;
981 font-family: var(--font-mono);
982 font-size: 11px;
983 padding: 1px 6px;
984 background: var(--bg-tertiary);
985 border: 1px solid var(--border);
986 border-radius: 3px;
987 margin-right: 4px;
927 .comment-editor textarea:focus { outline: none; }
928 .editor-preview {
929 min-height: 120px;
930 background: var(--bg);
988931 }
989932
990 /* Releases */
991 .release-card {
992 border: 1px solid var(--border);
993 border-radius: var(--radius);
994 background: var(--bg-secondary);
995 padding: 16px;
996 margin-bottom: 16px;
997 }
998 .release-header {
999 display: flex;
1000 justify-content: space-between;
1001 align-items: baseline;
1002 margin-bottom: 8px;
1003 gap: 12px;
1004 flex-wrap: wrap;
1005 }
1006 .release-name { font-size: 18px; font-weight: 600; }
1007 .release-tag {
1008 font-family: var(--font-mono);
933 /* Success Button */
934 .btn-success { background: var(--green); border-color: var(--green); color: #fff; }
935 .btn-success:hover { background: #2ea043; }
936 .btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
937 .btn-ghost:hover { color: var(--text); background: var(--bg-tertiary); }
938 .btn-lg { padding: 12px 24px; font-size: 16px; }
939
940 /* Tab count badge */
941 .tab-count {
942 display: inline-block;
943 padding: 0 6px;
944 margin-left: 4px;
1009945 font-size: 12px;
1010 padding: 2px 8px;
1011946 background: var(--bg-tertiary);
1012 border: 1px solid var(--border);
1013 border-radius: var(--radius);
1014 color: var(--green);
1015 }
1016 .release-latest { background: var(--green); color: var(--bg); border-color: var(--green); }
1017
1018 /* Gate runs */
1019 .gate-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
1020 .gate-run-row {
1021 display: flex;
1022 align-items: center;
1023 gap: 12px;
1024 padding: 10px 14px;
1025 border-bottom: 1px solid var(--border);
1026 font-size: 13px;
1027 }
1028 .gate-run-row:last-child { border-bottom: none; }
1029 .gate-status {
1030 display: inline-flex;
1031 align-items: center;
1032 gap: 4px;
1033 padding: 2px 8px;
1034947 border-radius: 10px;
1035 font-size: 11px;
1036 font-weight: 600;
1037 text-transform: uppercase;
1038948 }
1039 .gate-status.passed { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
1040 .gate-status.failed { background: rgba(248, 81, 73, 0.15); color: var(--red); border: 1px solid var(--red); }
1041 .gate-status.repaired { background: rgba(188, 140, 255, 0.15); color: #bc8cff; border: 1px solid #bc8cff; }
1042 .gate-status.skipped { background: var(--bg-tertiary); color: var(--text-muted); border: 1px solid var(--border); }
1043 .gate-status.running, .gate-status.pending { background: rgba(210, 153, 34, 0.15); color: var(--yellow); border: 1px solid var(--yellow); }
1044949
1045 /* Search */
1046 .search-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
1047 .search-hit {
1048 border: 1px solid var(--border);
1049 border-radius: var(--radius);
1050 padding: 12px 16px;
1051 background: var(--bg-secondary);
1052 margin-bottom: 8px;
950 /* Copy block */
951 .copy-block {
952 margin: 8px 0;
1053953 }
1054 .search-hit h4 { font-size: 14px; margin-bottom: 4px; }
1055 .search-hit .hit-path { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
1056 .search-hit pre { margin-top: 8px; font-size: 12px; }
1057954
1058 /* Audit log */
1059 .audit-log {
1060 border: 1px solid var(--border);
1061 border-radius: var(--radius);
1062 overflow-x: auto;
1063 background: var(--bg-secondary);
955 /* Progress bar */
956 .progress-bar {
957 width: 100%;
958 height: 8px;
959 background: var(--bg-tertiary);
960 border-radius: 4px;
961 overflow: hidden;
1064962 }
1065 .audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
1066 .audit-table th, .audit-table td {
1067 text-align: left;
1068 padding: 8px 12px;
1069 border-bottom: 1px solid var(--border);
1070 vertical-align: top;
963 .progress-fill {
964 height: 100%;
965 background: var(--accent);
966 border-radius: 4px;
967 transition: width 0.3s ease;
1071968 }
1072 .audit-table th {
1073 background: var(--bg-tertiary);
1074 font-size: 11px;
1075 text-transform: uppercase;
1076 letter-spacing: 0.5px;
1077 color: var(--text-muted);
1078 font-weight: 600;
969
970 /* Spinner */
971 .spinner {
972 border: 2px solid var(--border);
973 border-top: 2px solid var(--accent);
974 border-radius: 50%;
975 animation: spin 0.8s linear infinite;
1079976 }
1080 .audit-table tr:last-child td { border-bottom: none; }
1081 .audit-when { white-space: nowrap; color: var(--text-muted); }
1082 .audit-muted { color: var(--text-muted); font-style: italic; }
1083 .audit-action {
1084 font-family: var(--font-mono);
1085 font-size: 11px;
1086 padding: 1px 6px;
977 spin { to { transform: rotate(360deg); } }
978
979 /* Step indicator (onboarding) */
980 .step-indicator { margin-bottom: 32px; }
981 .step-circle {
982 width: 32px;
983 height: 32px;
984 border-radius: 50%;
985 display: flex;
986 align-items: center;
987 justify-content: center;
988 font-size: 14px;
989 font-weight: 600;
1087990 background: var(--bg-tertiary);
1088 border: 1px solid var(--border);
1089 border-radius: 3px;
1090 color: var(--text-link);
1091 }
1092 .audit-ip, .audit-meta code {
1093 font-family: var(--font-mono);
1094 font-size: 11px;
991 border: 2px solid var(--border);
1095992 color: var(--text-muted);
1096993 }
1097 .audit-target code {
1098 font-family: var(--font-mono);
1099 font-size: 11px;
1100 color: var(--text-muted);
994 .step-completed { background: var(--green); border-color: var(--green); color: #fff; }
995 .step-active { border-color: var(--accent); color: var(--accent); }
996 .step-line {
997 flex: 1;
998 height: 2px;
999 background: var(--border);
1000 min-width: 40px;
11011001 }
1002 .step-line[data-completed="true"] { background: var(--green); }
11021003
1103 /* Reactions */
1104 .reactions {
1105 display: flex;
1106 gap: 6px;
1107 flex-wrap: wrap;
1108 margin-top: 8px;
1004 /* Welcome hero */
1005 .welcome-hero {
1006 text-align: center;
1007 padding: 60px 20px 40px;
1008 max-width: 700px;
1009 margin: 0 auto;
11091010 }
1110 .reaction-btn {
1111 display: inline-flex;
1112 align-items: center;
1113 gap: 4px;
1114 padding: 2px 8px;
1115 border-radius: 12px;
1011 .welcome-hero h1 { font-size: 36px; margin-bottom: 12px; }
1012 .hero-subtitle { font-size: 18px; color: var(--text-muted); margin-bottom: 32px; }
1013
1014 /* Feature cards */
1015 .feature-card {
1016 text-align: center;
1017 padding: 24px;
1018 transition: border-color 0.2s, transform 0.2s;
1019 }
1020 .feature-card:hover { border-color: var(--accent); transform: translateY(-2px); }
1021 .feature-icon { font-size: 36px; margin-bottom: 12px; }
1022 .feature-card h3 { font-size: 16px; margin-bottom: 8px; }
1023
1024 /* Tooltip */
1025 .tooltip-wrapper { position: relative; }
1026 .tooltip-wrapper:hover::after {
1027 content: attr(data-tooltip);
1028 position: absolute;
1029 bottom: 100%;
1030 left: 50%;
1031 transform: translateX(-50%);
1032 padding: 4px 8px;
11161033 background: var(--bg-tertiary);
11171034 border: 1px solid var(--border);
1118 color: var(--text);
1035 border-radius: 4px;
11191036 font-size: 12px;
1120 cursor: pointer;
1121 font-family: inherit;
1037 white-space: nowrap;
1038 z-index: 100;
1039 margin-bottom: 4px;
11221040 }
1123 .reaction-btn:hover { background: var(--border); }
1124 .reaction-btn.active { background: rgba(31, 111, 235, 0.15); border-color: var(--accent); color: var(--accent); }
1125 .reaction-picker {
1041
1042 /* Notification bell */
1043 .notification-bell {
1044 position: relative;
11261045 display: inline-flex;
1127 gap: 4px;
11281046 align-items: center;
1047 color: var(--text-muted);
1048 padding: 4px;
11291049 }
1130 .reaction-picker form { display: inline; }
1131 .reaction-count { font-size: 11px; font-weight: 600; }
1132
1133 /* Saved replies */
1134 .saved-replies-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
1135 .saved-reply-item { border-bottom: 1px solid var(--border); background: var(--bg); }
1136 .saved-reply-item:last-child { border-bottom: none; }
1137 .saved-reply-item > summary {
1138 padding: 10px 16px;
1139 cursor: pointer;
1140 list-style: none;
1141 display: flex;
1142 align-items: center;
1050 .notification-bell:hover { color: var(--text); text-decoration: none; }
1051 .notification-count {
1052 position: absolute;
1053 top: -4px;
1054 right: -6px;
1055 background: var(--accent);
1056 color: #fff;
1057 font-size: 10px;
1058 font-weight: 700;
1059 padding: 1px 5px;
1060 border-radius: 10px;
1061 min-width: 16px;
1062 text-align: center;
11431063 }
1144 .saved-reply-item > summary::-webkit-details-marker { display: none; }
1145 .saved-reply-item > summary:hover { background: var(--bg-secondary); }
1146 .saved-reply-item code { font-family: var(--font-mono); font-size: 12px; color: var(--text-link); }
11471064
1148 /* Draft PR */
1149 .draft-badge {
1150 background: rgba(139, 148, 158, 0.15);
1151 color: var(--text-muted);
1152 border: 1px solid var(--text-muted);
1065 /* Alert variants */
1066 .alert-warning {
1067 background: rgba(210, 153, 34, 0.1);
1068 border: 1px solid var(--yellow);
1069 color: var(--yellow);
1070 padding: 8px 12px;
1071 border-radius: var(--radius);
1072 margin-bottom: 16px;
1073 font-size: 14px;
11531074 }
1154 .state-draft { color: var(--text-muted); }
1155
1156 /* Toasts (prepared for future UI hooks) */
1157 .toast-container {
1158 position: fixed; bottom: 24px; right: 24px;
1159 z-index: 50; display: flex; flex-direction: column; gap: 8px;
1075 .alert-info {
1076 background: rgba(88, 166, 255, 0.1);
1077 border: 1px solid var(--text-link);
1078 color: var(--text-link);
1079 padding: 8px 12px;
1080 border-radius: var(--radius);
1081 margin-bottom: 16px;
1082 font-size: 14px;
11601083 }
11611084
1162 /* Mobile */
1085 /* Badge variants */
1086 .badge-success { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
1087 .badge-danger { background: rgba(248, 81, 73, 0.1); color: var(--red); border: 1px solid var(--red); }
1088 .badge-warning { background: rgba(210, 153, 34, 0.1); color: var(--yellow); border: 1px solid var(--yellow); }
1089
1090 /* Mobile responsiveness */
11631091 (max-width: 768px) {
1164 header nav { flex-wrap: wrap; gap: 8px; }
1165 .nav-search { max-width: 100%; margin: 8px 0; order: 3; width: 100%; }
1166 .nav-right { flex-wrap: wrap; gap: 8px; }
11671092 main { padding: 16px; }
1168 .dashboard-grid { grid-template-columns: 1fr; }
11691093 .card-grid { grid-template-columns: 1fr; }
11701094 .user-profile { flex-direction: column; gap: 16px; }
11711095 .repo-header { flex-wrap: wrap; }
1096 .repo-header-actions { margin-left: 0; }
11721097 .repo-nav { overflow-x: auto; }
1098 .blob-code { font-size: 12px; }
1099 .diff-content { font-size: 12px; }
1100 .hamburger-btn {
1101 display: inline-flex;
1102 align-items: center;
1103 justify-content: center;
1104 width: 36px;
1105 height: 36px;
1106 background: none;
1107 border: 1px solid var(--border);
1108 border-radius: var(--radius);
1109 color: var(--text);
1110 font-size: 18px;
1111 cursor: pointer;
1112 }
1113 .mobile-hidden { display: none !important; }
1114 .mobile-visible {
1115 display: flex !important;
1116 position: absolute;
1117 top: 100%;
1118 right: 0;
1119 background: var(--bg-secondary);
1120 border: 1px solid var(--border);
1121 border-radius: var(--radius);
1122 padding: 8px;
1123 flex-direction: column;
1124 gap: 4px;
1125 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1126 min-width: 200px;
1127 }
1128 .mobile-visible a, .mobile-visible button {
1129 padding: 8px 12px;
1130 display: block;
1131 width: 100%;
1132 text-align: left;
1133 }
1134 .issue-item { flex-wrap: wrap; }
1135 .commit-item { flex-direction: column; gap: 8px; }
1136 .settings-container { max-width: 100%; }
1137 .auth-container { max-width: 100%; }
11731138 }
1139
1140 /* Focus visible for keyboard nav */
1141 :focus-visible {
1142 outline: 2px solid var(--accent);
1143 outline-offset: 2px;
1144 }
1145
1146 /* Skip to main content (accessibility) */
1147 .skip-link {
1148 position: absolute;
1149 top: -100px;
1150 left: 0;
1151 background: var(--accent);
1152 color: #fff;
1153 padding: 8px 16px;
1154 z-index: 9999;
1155 font-size: 14px;
1156 }
1157 .skip-link:focus { top: 0; }
1158
1159 /* Markdown body spacing */
1160 .markdown-body { padding: 16px; }
1161 .markdown-body h1, .markdown-body h2, .markdown-body h3 { margin-top: 1.5em; margin-bottom: 0.5em; }
1162 .markdown-body p { margin-bottom: 1em; }
1163 .markdown-body pre { margin: 1em 0; }
1164 .markdown-body code { font-size: 85%; }
1165 .markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 1em; }
11741166`;
Addedsrc/views/ui.tsx+701−0View fileUnifiedSplit
@@ -0,0 +1,701 @@
1/**
2 * Core UI Component Library — gluecron design system.
3 *
4 * Pure components. No raw HTML in routes. Every visual element
5 * is a composable, typed, reusable component.
6 */
7
8import type { FC, PropsWithChildren } from "hono/jsx";
9import { html } from "hono/html";
10
11// ─── Primitive Components ───────────────────────────────────────────────────
12
13/** Flex container with gap and alignment */
14export const Flex: FC<
15 PropsWithChildren<{
16 direction?: "row" | "column";
17 gap?: number;
18 align?: string;
19 justify?: string;
20 wrap?: boolean;
21 class?: string;
22 style?: string;
23 }>
24> = ({ children, direction = "row", gap = 0, align, justify, wrap, class: cls, style }) => (
25 <div
26 class={cls || ""}
27 style={`display:flex;flex-direction:${direction};${gap ? `gap:${gap}px;` : ""}${align ? `align-items:${align};` : ""}${justify ? `justify-content:${justify};` : ""}${wrap ? "flex-wrap:wrap;" : ""}${style || ""}`}
28 >
29 {children}
30 </div>
31);
32
33/** Grid container */
34export const Grid: FC<
35 PropsWithChildren<{ cols?: string; gap?: number; class?: string }>
36> = ({ children, cols = "repeat(auto-fill, minmax(340px, 1fr))", gap = 16, class: cls }) => (
37 <div class={cls || "card-grid"} style={`display:grid;grid-template-columns:${cols};gap:${gap}px;`}>
38 {children}
39 </div>
40);
41
42/** Spacer element */
43export const Spacer: FC<{ size?: number }> = ({ size = 16 }) => (
44 <div style={`height:${size}px`} />
45);
46
47/** Text with semantic styling */
48export const Text: FC<
49 PropsWithChildren<{
50 size?: number;
51 color?: string;
52 weight?: number | string;
53 mono?: boolean;
54 muted?: boolean;
55 style?: string;
56 }>
57> = ({ children, size, color, weight, mono, muted, style }) => (
58 <span
59 style={`${size ? `font-size:${size}px;` : ""}${color ? `color:${color};` : ""}${weight ? `font-weight:${weight};` : ""}${mono ? "font-family:var(--font-mono);" : ""}${muted ? "color:var(--text-muted);" : ""}${style || ""}`}
60 >
61 {children}
62 </span>
63);
64
65// ─── Buttons ────────────────────────────────────────────────────────────────
66
67export const Button: FC<
68 PropsWithChildren<{
69 variant?: "default" | "primary" | "danger" | "success" | "ghost";
70 size?: "sm" | "md" | "lg";
71 type?: "button" | "submit" | "reset";
72 disabled?: boolean;
73 formaction?: string;
74 class?: string;
75 }>
76> = ({ children, variant = "default", size = "md", type = "button", disabled, formaction, class: cls }: any) => {
77 const variantCls =
78 variant === "primary" ? " btn-primary" :
79 variant === "danger" ? " btn-danger" :
80 variant === "success" ? " btn-success" :
81 variant === "ghost" ? " btn-ghost" : "";
82 const sizeCls = size === "sm" ? " btn-sm" : size === "lg" ? " btn-lg" : "";
83 return (
84 <button
85 type={type}
86 class={`btn${variantCls}${sizeCls}${cls ? ` ${cls}` : ""}`}
87 disabled={disabled}
88 formaction={formaction}
89 >
90 {children}
91 </button>
92 );
93};
94
95export const LinkButton: FC<
96 PropsWithChildren<{
97 href: string;
98 variant?: "default" | "primary" | "danger" | "success";
99 size?: "sm" | "md";
100 }>
101> = ({ children, href, variant = "default", size = "md" }) => {
102 const variantCls = variant === "primary" ? " btn-primary" : variant === "danger" ? " btn-danger" : variant === "success" ? " btn-success" : "";
103 const sizeCls = size === "sm" ? " btn-sm" : "";
104 return (
105 <a href={href} class={`btn${variantCls}${sizeCls}`}>
106 {children}
107 </a>
108 );
109};
110
111// ─── Forms ──────────────────────────────────────────────────────────────────
112
113export const Form: FC<
114 PropsWithChildren<{
115 action: string;
116 method?: string;
117 csrfToken?: string;
118 class?: string;
119 }>
120> = ({ children, action, method = "POST", csrfToken, class: cls }) => (
121 <form method={method.toLowerCase() as any} action={action} class={cls || ""}>
122 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
123 {children}
124 </form>
125);
126
127export const FormGroup: FC<
128 PropsWithChildren<{ label?: string; htmlFor?: string; hint?: string }>
129> = ({ children, label, htmlFor, hint }) => (
130 <div class="form-group">
131 {label && <label for={htmlFor}>{label}</label>}
132 {children}
133 {hint && <Text size={12} muted>{hint}</Text>}
134 </div>
135);
136
137export const Input: FC<{
138 name: string;
139 type?: string;
140 id?: string;
141 value?: string;
142 placeholder?: string;
143 required?: boolean;
144 disabled?: boolean;
145 pattern?: string;
146 autocomplete?: string;
147 autofocus?: boolean;
148 minLength?: number;
149 maxLength?: number;
150 style?: string;
151}> = (props) => (
152 <input
153 type={props.type || "text"}
154 id={props.id || props.name}
155 name={props.name}
156 value={props.value}
157 placeholder={props.placeholder}
158 required={props.required}
159 disabled={props.disabled}
160 pattern={props.pattern}
161 autocomplete={props.autocomplete}
162 autofocus={props.autofocus}
163 minLength={props.minLength}
164 maxLength={props.maxLength}
165 class={props.disabled ? "input-disabled" : ""}
166 style={props.style}
167 />
168);
169
170export const TextArea: FC<{
171 name: string;
172 id?: string;
173 rows?: number;
174 placeholder?: string;
175 required?: boolean;
176 value?: string;
177 mono?: boolean;
178 style?: string;
179}> = (props) => (
180 <textarea
181 id={props.id || props.name}
182 name={props.name}
183 rows={props.rows || 6}
184 placeholder={props.placeholder}
185 required={props.required}
186 style={`${props.mono ? "font-family:var(--font-mono);font-size:13px;" : ""}${props.style || ""}`}
187 >
188 {props.value}
189 </textarea>
190);
191
192export const Select: FC<
193 PropsWithChildren<{ name: string; id?: string; value?: string }>
194> = ({ children, name, id, value }) => (
195 <select id={id || name} name={name} value={value}>
196 {children}
197 </select>
198);
199
200// ─── Feedback Components ────────────────────────────────────────────────────
201
202export const Alert: FC<
203 PropsWithChildren<{ variant: "error" | "success" | "warning" | "info" }>
204> = ({ children, variant }) => {
205 const cls =
206 variant === "error" ? "auth-error" :
207 variant === "success" ? "auth-success" :
208 variant === "warning" ? "alert-warning" :
209 "alert-info";
210 return <div class={cls}>{children}</div>;
211};
212
213export const EmptyState: FC<
214 PropsWithChildren<{ title?: string; icon?: string }>
215> = ({ children, title, icon }) => (
216 <div class="empty-state">
217 {icon && <div style="font-size:48px;margin-bottom:12px">{icon}</div>}
218 {title && <h2>{title}</h2>}
219 {children}
220 </div>
221);
222
223export const Badge: FC<
224 PropsWithChildren<{
225 variant?: "default" | "open" | "closed" | "merged" | "success" | "danger" | "warning";
226 style?: string;
227 }>
228> = ({ children, variant = "default", style }) => {
229 const cls =
230 variant === "open" ? "badge-open" :
231 variant === "closed" ? "badge-closed" :
232 variant === "merged" ? "badge-merged" :
233 variant === "success" ? "badge-success" :
234 variant === "danger" ? "badge-danger" :
235 variant === "warning" ? "badge-warning" :
236 "badge";
237 return <span class={`issue-badge ${cls}`} style={style}>{children}</span>;
238};
239
240// ─── Card Components ────────────────────────────────────────────────────────
241
242export const Card: FC<PropsWithChildren<{ class?: string; style?: string }>> = ({
243 children,
244 class: cls,
245 style,
246}) => (
247 <div class={`card${cls ? ` ${cls}` : ""}`} style={style}>
248 {children}
249 </div>
250);
251
252export const CardMeta: FC<PropsWithChildren> = ({ children }) => (
253 <div class="card-meta">{children}</div>
254);
255
256// ─── Navigation Components ──────────────────────────────────────────────────
257
258export const TabNav: FC<{
259 tabs: Array<{ label: string; href: string; active?: boolean; count?: number }>;
260}> = ({ tabs }) => (
261 <div class="repo-nav">
262 {tabs.map((tab) => (
263 <a href={tab.href} class={tab.active ? "active" : ""}>
264 {tab.label}
265 {tab.count !== undefined && (
266 <span class="tab-count">{tab.count}</span>
267 )}
268 </a>
269 ))}
270 </div>
271);
272
273export const FilterTabs: FC<{
274 tabs: Array<{ label: string; href: string; active?: boolean }>;
275}> = ({ tabs }) => (
276 <div class="issue-tabs">
277 {tabs.map((tab) => (
278 <a href={tab.href} class={tab.active ? "active" : ""}>
279 {tab.label}
280 </a>
281 ))}
282 </div>
283);
284
285// ─── Page Layout Components ─────────────────────────────────────────────────
286
287export const PageHeader: FC<
288 PropsWithChildren<{ title: string; actions?: any }>
289> = ({ title, actions, children }) => (
290 <Flex justify="space-between" align="center" style="margin-bottom:20px">
291 <h2>{title}</h2>
292 {actions}
293 {children}
294 </Flex>
295);
296
297export const Section: FC<
298 PropsWithChildren<{ title?: string; style?: string }>
299> = ({ children, title, style }) => (
300 <div style={`margin-bottom:24px;${style || ""}`}>
301 {title && <h3 style="margin-bottom:12px">{title}</h3>}
302 {children}
303 </div>
304);
305
306export const Container: FC<
307 PropsWithChildren<{ maxWidth?: number; class?: string }>
308> = ({ children, maxWidth = 800, class: cls }) => (
309 <div class={cls || ""} style={`max-width:${maxWidth}px`}>
310 {children}
311 </div>
312);
313
314// ─── Data Display Components ────────────────────────────────────────────────
315
316export const StatGroup: FC<{
317 stats: Array<{ label: string; value: string | number; color?: string }>;
318}> = ({ stats }) => (
319 <Flex gap={24} wrap>
320 {stats.map((stat) => (
321 <div>
322 <div style={`font-size:24px;font-weight:700;${stat.color ? `color:${stat.color};` : ""}`}>
323 {stat.value}
324 </div>
325 <Text size={13} muted>{stat.label}</Text>
326 </div>
327 ))}
328 </Flex>
329);
330
331export const KeyValue: FC<{ label: string; value: string | number }> = ({
332 label,
333 value,
334}) => (
335 <Flex justify="space-between" align="center" style="padding:8px 0;border-bottom:1px solid var(--border)">
336 <Text size={14} muted>{label}</Text>
337 <Text size={14}>{String(value)}</Text>
338 </Flex>
339);
340
341export const DataTable: FC<{
342 headers: string[];
343 rows: Array<Array<string | any>>;
344 class?: string;
345}> = ({ headers, rows, class: cls }) => (
346 <table class={cls || "file-table"}>
347 <thead>
348 <tr>
349 {headers.map((h) => (
350 <th style="padding:8px 16px;text-align:left;font-size:13px;color:var(--text-muted);border-bottom:1px solid var(--border)">{h}</th>
351 ))}
352 </tr>
353 </thead>
354 <tbody>
355 {rows.map((row) => (
356 <tr>
357 {row.map((cell) => (
358 <td style="padding:8px 16px;font-size:14px">{cell}</td>
359 ))}
360 </tr>
361 ))}
362 </tbody>
363 </table>
364);
365
366// ─── List Components ────────────────────────────────────────────────────────
367
368export const ListItem: FC<
369 PropsWithChildren<{ style?: string }>
370> = ({ children, style }) => (
371 <div class="issue-item" style={style}>
372 {children}
373 </div>
374);
375
376export const List: FC<PropsWithChildren<{ class?: string }>> = ({ children, class: cls }) => (
377 <div class={cls || "issue-list"}>
378 {children}
379 </div>
380);
381
382// ─── Code Display ───────────────────────────────────────────────────────────
383
384export const CodeBlock: FC<{
385 code: string;
386 language?: string;
387 showLineNumbers?: boolean;
388}> = ({ code, showLineNumbers = true }) => {
389 const lines = code.split("\n");
390 if (lines[lines.length - 1] === "") lines.pop();
391 return (
392 <div class="blob-code">
393 <table>
394 <tbody>
395 {lines.map((line, i) => (
396 <tr>
397 {showLineNumbers && <td class="line-num">{i + 1}</td>}
398 <td class="line-content">{line}</td>
399 </tr>
400 ))}
401 </tbody>
402 </table>
403 </div>
404 );
405};
406
407export const InlineCode: FC<PropsWithChildren> = ({ children }) => (
408 <code style="font-size:12px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-family:var(--font-mono)">
409 {children}
410 </code>
411);
412
413export const CopyBlock: FC<{
414 text: string;
415 label?: string;
416}> = ({ text, label }) => (
417 <Flex gap={8} align="center" class="copy-block">
418 {label && <Text size={13} muted>{label}</Text>}
419 <code
420 style="flex:1;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);font-family:var(--font-mono);font-size:13px;overflow-x:auto"
421 data-copy={text}
422 >
423 {text}
424 </code>
425 <button
426 type="button"
427 class="btn btn-sm copy-btn"
428 data-clipboard={text}
429 title="Copy to clipboard"
430 >
431 Copy
432 </button>
433 </Flex>
434);
435
436// ─── Notification Components ────────────────────────────────────────────────
437
438export const NotificationBell: FC<{ count: number; href: string }> = ({
439 count,
440 href,
441}) => (
442 <a href={href} class="notification-bell" title="Notifications">
443 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
444 <path d="M8 16a2 2 0 002-2H6a2 2 0 002 2zM8 1.918l-.797.161A4.002 4.002 0 004 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 00-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 111.99 0A5.002 5.002 0 0113 6c0 .88.32 4.2 1.22 6z" />
445 </svg>
446 {count > 0 && <span class="notification-count">{count > 99 ? "99+" : count}</span>}
447 </a>
448);
449
450// ─── Profile Components ─────────────────────────────────────────────────────
451
452export const Avatar: FC<{
453 name: string;
454 url?: string;
455 size?: number;
456}> = ({ name, url, size = 40 }) => {
457 if (url) {
458 return (
459 <img
460 src={url}
461 alt={name}
462 style={`width:${size}px;height:${size}px;border-radius:50%;object-fit:cover`}
463 loading="lazy"
464 />
465 );
466 }
467 return (
468 <div
469 class="user-avatar"
470 style={`width:${size}px;height:${size}px;font-size:${size * 0.4}px`}
471 >
472 {name[0].toUpperCase()}
473 </div>
474 );
475};
476
477export const UserCard: FC<{
478 username: string;
479 displayName?: string | null;
480 bio?: string | null;
481 avatarUrl?: string | null;
482}> = ({ username, displayName, bio, avatarUrl }) => (
483 <div class="user-profile">
484 <Avatar name={displayName || username} url={avatarUrl || undefined} size={96} />
485 <div class="user-info">
486 <h2>{displayName || username}</h2>
487 <div class="username">@{username}</div>
488 {bio && <div class="bio">{bio}</div>}
489 </div>
490 </div>
491);
492
493// ─── Onboarding Components ──────────────────────────────────────────────────
494
495export const StepIndicator: FC<{
496 steps: Array<{ label: string; completed: boolean; active: boolean }>;
497}> = ({ steps }) => (
498 <Flex gap={0} align="center" class="step-indicator">
499 {steps.map((step, i) => (
500 <>
501 {i > 0 && <div class="step-line" data-completed={step.completed || steps[i - 1]?.completed ? "true" : "false"} />}
502 <Flex direction="column" align="center" gap={4}>
503 <div
504 class={`step-circle${step.completed ? " step-completed" : ""}${step.active ? " step-active" : ""}`}
505 >
506 {step.completed ? "\u2713" : i + 1}
507 </div>
508 <Text size={12} muted={!step.active}>{step.label}</Text>
509 </Flex>
510 </>
511 ))}
512 </Flex>
513);
514
515export const WelcomeHero: FC<
516 PropsWithChildren<{ title: string; subtitle?: string }>
517> = ({ children, title, subtitle }) => (
518 <div class="welcome-hero">
519 <h1>{title}</h1>
520 {subtitle && <p class="hero-subtitle">{subtitle}</p>}
521 {children}
522 </div>
523);
524
525export const FeatureCard: FC<{
526 icon: string;
527 title: string;
528 description: string;
529 href?: string;
530}> = ({ icon, title, description, href }) => {
531 const content = (
532 <Card class="feature-card">
533 <div class="feature-icon">{icon}</div>
534 <h3>{title}</h3>
535 <Text size={13} muted>{description}</Text>
536 </Card>
537 );
538 return href ? <a href={href} style="text-decoration:none">{content}</a> : content;
539};
540
541// ─── Search Components ──────────────────────────────────────────────────────
542
543export const SearchBar: FC<{
544 action: string;
545 value?: string;
546 placeholder?: string;
547 name?: string;
548}> = ({ action, value, placeholder = "Search...", name = "q" }) => (
549 <form method="get" action={action} style="margin-bottom:20px">
550 <Flex gap={8}>
551 <input
552 type="text"
553 name={name}
554 value={value}
555 placeholder={placeholder}
556 class="search-input"
557 autocomplete="off"
558 />
559 <Button type="submit" variant="primary">Search</Button>
560 </Flex>
561 </form>
562);
563
564export const SearchResults: FC<{
565 query: string;
566 count: number;
567}> = ({ query, count }) => (
568 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
569 {count} result{count !== 1 ? "s" : ""} for{" "}
570 <strong style="color:var(--text)">"{query}"</strong>
571 </p>
572);
573
574// ─── Markdown Content ───────────────────────────────────────────────────────
575
576export const MarkdownContent: FC<{ html: string }> = ({ html: htmlContent }) => (
577 <div class="markdown-body">
578 {html([htmlContent] as unknown as TemplateStringsArray)}
579 </div>
580);
581
582// ─── Comment Components ─────────────────────────────────────────────────────
583
584export const CommentBox: FC<{
585 author: string;
586 date: string | Date;
587 body: string;
588 isAi?: boolean;
589}> = ({ author, date, body, isAi }) => {
590 const dateStr = typeof date === "string" ? date : date.toISOString();
591 return (
592 <div class={`issue-comment-box${isAi ? " ai-review" : ""}`}>
593 <div class="comment-header">
594 <Flex gap={8} align="center">
595 <strong>{author}</strong>
596 {isAi && <Badge variant="default" style="font-size:11px">AI Review</Badge>}
597 <Text size={13} muted>commented {formatRelative(dateStr)}</Text>
598 </Flex>
599 </div>
600 <MarkdownContent html={body} />
601 </div>
602 );
603};
604
605export const CommentForm: FC<{
606 action: string;
607 csrfToken?: string;
608 placeholder?: string;
609 submitLabel?: string;
610 extraActions?: any;
611}> = ({ action, csrfToken, placeholder = "Leave a comment... (Markdown supported)", submitLabel = "Comment", extraActions }) => (
612 <div style="margin-top:20px">
613 <Form action={action} csrfToken={csrfToken}>
614 <FormGroup>
615 <div class="comment-editor">
616 <div class="editor-tabs">
617 <button type="button" class="editor-tab active" data-tab="write">Write</button>
618 <button type="button" class="editor-tab" data-tab="preview">Preview</button>
619 </div>
620 <TextArea
621 name="body"
622 rows={6}
623 required
624 placeholder={placeholder}
625 mono
626 />
627 <div class="editor-preview" style="display:none" />
628 </div>
629 </FormGroup>
630 <Flex gap={8}>
631 <Button type="submit" variant="primary">{submitLabel}</Button>
632 {extraActions}
633 </Flex>
634 </Form>
635 </div>
636);
637
638// ─── Tooltip Component ──────────────────────────────────────────────────────
639
640export const Tooltip: FC<PropsWithChildren<{ text: string }>> = ({
641 children,
642 text,
643}) => (
644 <span class="tooltip-wrapper" data-tooltip={text}>
645 {children}
646 </span>
647);
648
649// ─── Loading & Progress ─────────────────────────────────────────────────────
650
651export const Spinner: FC<{ size?: number }> = ({ size = 20 }) => (
652 <div
653 class="spinner"
654 style={`width:${size}px;height:${size}px`}
655 />
656);
657
658export const ProgressBar: FC<{ value: number; max?: number; color?: string }> = ({
659 value,
660 max = 100,
661 color,
662}) => (
663 <div class="progress-bar">
664 <div
665 class="progress-fill"
666 style={`width:${(value / max) * 100}%;${color ? `background:${color};` : ""}`}
667 />
668 </div>
669);
670
671// ─── Keyboard Shortcut Hint ─────────────────────────────────────────────────
672
673export const Kbd: FC<PropsWithChildren> = ({ children }) => (
674 <kbd class="kbd">{children}</kbd>
675);
676
677// ─── Utility Functions ──────────────────────────────────────────────────────
678
679export function formatRelative(dateStr: string | Date): string {
680 const date = typeof dateStr === "string" ? new Date(dateStr) : dateStr;
681 const now = new Date();
682 const diffMs = now.getTime() - date.getTime();
683 const diffMins = Math.floor(diffMs / 60000);
684 if (diffMins < 1) return "just now";
685 if (diffMins < 60) return `${diffMins}m ago`;
686 const diffHours = Math.floor(diffMins / 60);
687 if (diffHours < 24) return `${diffHours}h ago`;
688 const diffDays = Math.floor(diffHours / 24);
689 if (diffDays < 30) return `${diffDays}d ago`;
690 return date.toLocaleDateString("en-US", {
691 month: "short",
692 day: "numeric",
693 year: "numeric",
694 });
695}
696
697export function formatSize(bytes: number): string {
698 if (bytes < 1024) return `${bytes} B`;
699 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
700 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
701}
0702