Commit3ef4c9dunknown_key
feat: full green ecosystem — AI auto-repair, notifications, dashboard, releases, gates, insights, search, observability
feat: full green ecosystem — AI auto-repair, notifications, dashboard, releases, gates, insights, search, observability Green-ecosystem-by-default: every new repo auto-configures gates, branch protection, CODEOWNERS sync, welcome issue, and labels so nothing broken reaches production. New runtime infrastructure: - AI client + generators (commit/PR/changelog), repo-grounded chat - Auto-repair engine commits patches back via disposable worktrees - Regex + AI security/secret scanners run on every push - CODEOWNERS parser with last-match-wins + auto reviewer assignment - Rate limiter, request-context middleware, health + metrics endpoints New UX: - Notifications inbox with unread badge wired into global nav - Logged-in dashboard aggregating reviews, PRs, issues, gate health - /ask global + per-repo AI chat grounded in README/tree/commits - Releases with AI-generated changelogs and starrer notifications - Gates history + settings UI with branch protection rules - Insights tab (green rate, 30-day commit chart, top contributors) - Milestones CRUD, global search (repos/users/issues/PRs), shortcuts page - Keyboard chords (g d / g n / g e / g a), Cmd+K → Ask AI, / → search Tests: - green-ecosystem.test.ts covers scanner, codeowners, health, rate-limit, search https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
32 files changed+6569−1233ef4c9da4cb85837c2a5a8355ebcf03fd0985eba
32 changed files+6569−123
Addeddrizzle/0001_green_ecosystem.sql+274−0View fileUnifiedSplit
@@ -0,0 +1,274 @@
1-- Gluecron migration 0001: green ecosystem — advanced platform features
2-- Adds: repo_settings, branch_protection, gate_runs, notifications, releases,
3-- milestones, reactions, pr_reviews, code_owners, ai_chats, audit_log,
4-- deployments, rate_limit_buckets, plus new columns on existing tables.
5
6--> statement-breakpoint
7ALTER TABLE "repositories" ADD COLUMN IF NOT EXISTS "is_archived" boolean DEFAULT false NOT NULL;
8
9--> statement-breakpoint
10ALTER TABLE "pull_requests" ADD COLUMN IF NOT EXISTS "is_draft" boolean DEFAULT false NOT NULL;
11
12--> statement-breakpoint
13ALTER TABLE "pull_requests" ADD COLUMN IF NOT EXISTS "merge_strategy" text DEFAULT 'merge' NOT NULL;
14
15--> statement-breakpoint
16ALTER TABLE "pull_requests" ADD COLUMN IF NOT EXISTS "milestone_id" uuid;
17
18--> statement-breakpoint
19CREATE TABLE IF NOT EXISTS "repo_settings" (
20 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
21 "repository_id" uuid NOT NULL UNIQUE,
22 "gate_test_enabled" boolean DEFAULT true NOT NULL,
23 "ai_review_enabled" boolean DEFAULT true NOT NULL,
24 "secret_scan_enabled" boolean DEFAULT true NOT NULL,
25 "security_scan_enabled" boolean DEFAULT true NOT NULL,
26 "dependency_scan_enabled" boolean DEFAULT true NOT NULL,
27 "lint_enabled" boolean DEFAULT true NOT NULL,
28 "type_check_enabled" boolean DEFAULT true NOT NULL,
29 "test_enabled" boolean DEFAULT true NOT NULL,
30 "auto_fix_enabled" boolean DEFAULT true NOT NULL,
31 "auto_merge_resolve_enabled" boolean DEFAULT true NOT NULL,
32 "auto_format_enabled" boolean DEFAULT true NOT NULL,
33 "ai_commit_messages_enabled" boolean DEFAULT true NOT NULL,
34 "ai_pr_summary_enabled" boolean DEFAULT true NOT NULL,
35 "ai_changelog_enabled" boolean DEFAULT true NOT NULL,
36 "auto_deploy_enabled" boolean DEFAULT true NOT NULL,
37 "deploy_require_all_green" boolean DEFAULT true NOT NULL,
38 "created_at" timestamp DEFAULT now() NOT NULL,
39 "updated_at" timestamp DEFAULT now() NOT NULL,
40 CONSTRAINT "repo_settings_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
41);
42
43--> statement-breakpoint
44CREATE TABLE IF NOT EXISTS "branch_protection" (
45 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
46 "repository_id" uuid NOT NULL,
47 "pattern" text NOT NULL,
48 "require_pull_request" boolean DEFAULT true NOT NULL,
49 "require_green_gates" boolean DEFAULT true NOT NULL,
50 "require_ai_approval" boolean DEFAULT true NOT NULL,
51 "require_human_review" boolean DEFAULT false NOT NULL,
52 "required_approvals" integer DEFAULT 0 NOT NULL,
53 "allow_force_push" boolean DEFAULT false NOT NULL,
54 "allow_deletion" boolean DEFAULT false NOT NULL,
55 "dismiss_stale_reviews" boolean DEFAULT true NOT NULL,
56 "created_at" timestamp DEFAULT now() NOT NULL,
57 "updated_at" timestamp DEFAULT now() NOT NULL,
58 CONSTRAINT "branch_protection_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
59);
60
61--> statement-breakpoint
62CREATE UNIQUE INDEX IF NOT EXISTS "branch_protection_repo_pattern" ON "branch_protection" ("repository_id", "pattern");
63
64--> statement-breakpoint
65CREATE TABLE IF NOT EXISTS "gate_runs" (
66 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
67 "repository_id" uuid NOT NULL,
68 "pull_request_id" uuid,
69 "commit_sha" text NOT NULL,
70 "ref" text NOT NULL,
71 "gate_name" text NOT NULL,
72 "status" text NOT NULL,
73 "summary" text,
74 "details" text,
75 "repair_attempted" boolean DEFAULT false NOT NULL,
76 "repair_succeeded" boolean DEFAULT false NOT NULL,
77 "repair_commit_sha" text,
78 "duration_ms" integer,
79 "created_at" timestamp DEFAULT now() NOT NULL,
80 "completed_at" timestamp,
81 CONSTRAINT "gate_runs_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
82 CONSTRAINT "gate_runs_pr_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests" ("id") ON DELETE CASCADE
83);
84
85--> statement-breakpoint
86CREATE INDEX IF NOT EXISTS "gate_runs_repo_sha" ON "gate_runs" ("repository_id", "commit_sha");
87
88--> statement-breakpoint
89CREATE INDEX IF NOT EXISTS "gate_runs_pr" ON "gate_runs" ("pull_request_id");
90
91--> statement-breakpoint
92CREATE INDEX IF NOT EXISTS "gate_runs_created" ON "gate_runs" ("created_at");
93
94--> statement-breakpoint
95CREATE TABLE IF NOT EXISTS "notifications" (
96 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
97 "user_id" uuid NOT NULL,
98 "repository_id" uuid,
99 "kind" text NOT NULL,
100 "title" text NOT NULL,
101 "body" text,
102 "url" text,
103 "read_at" timestamp,
104 "created_at" timestamp DEFAULT now() NOT NULL,
105 CONSTRAINT "notifications_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
106 CONSTRAINT "notifications_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
107);
108
109--> statement-breakpoint
110CREATE INDEX IF NOT EXISTS "notifications_user_unread" ON "notifications" ("user_id", "read_at");
111
112--> statement-breakpoint
113CREATE INDEX IF NOT EXISTS "notifications_user_created" ON "notifications" ("user_id", "created_at");
114
115--> statement-breakpoint
116CREATE TABLE IF NOT EXISTS "releases" (
117 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
118 "repository_id" uuid NOT NULL,
119 "author_id" uuid NOT NULL,
120 "tag" text NOT NULL,
121 "name" text NOT NULL,
122 "body" text,
123 "target_commit" text NOT NULL,
124 "is_draft" boolean DEFAULT false NOT NULL,
125 "is_prerelease" boolean DEFAULT false NOT NULL,
126 "created_at" timestamp DEFAULT now() NOT NULL,
127 "published_at" timestamp,
128 CONSTRAINT "releases_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
129 CONSTRAINT "releases_author_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
130);
131
132--> statement-breakpoint
133CREATE UNIQUE INDEX IF NOT EXISTS "releases_repo_tag" ON "releases" ("repository_id", "tag");
134
135--> statement-breakpoint
136CREATE TABLE IF NOT EXISTS "milestones" (
137 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
138 "repository_id" uuid NOT NULL,
139 "title" text NOT NULL,
140 "description" text,
141 "state" text DEFAULT 'open' NOT NULL,
142 "due_date" timestamp,
143 "created_at" timestamp DEFAULT now() NOT NULL,
144 "closed_at" timestamp,
145 CONSTRAINT "milestones_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
146);
147
148--> statement-breakpoint
149CREATE INDEX IF NOT EXISTS "milestones_repo_state" ON "milestones" ("repository_id", "state");
150
151--> statement-breakpoint
152CREATE TABLE IF NOT EXISTS "reactions" (
153 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
154 "user_id" uuid NOT NULL,
155 "target_type" text NOT NULL,
156 "target_id" uuid NOT NULL,
157 "emoji" text NOT NULL,
158 "created_at" timestamp DEFAULT now() NOT NULL,
159 CONSTRAINT "reactions_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
160);
161
162--> statement-breakpoint
163CREATE UNIQUE INDEX IF NOT EXISTS "reactions_unique" ON "reactions" ("user_id", "target_type", "target_id", "emoji");
164
165--> statement-breakpoint
166CREATE INDEX IF NOT EXISTS "reactions_target" ON "reactions" ("target_type", "target_id");
167
168--> statement-breakpoint
169CREATE TABLE IF NOT EXISTS "pr_reviews" (
170 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
171 "pull_request_id" uuid NOT NULL,
172 "reviewer_id" uuid NOT NULL,
173 "state" text NOT NULL,
174 "body" text,
175 "is_ai" boolean DEFAULT false NOT NULL,
176 "created_at" timestamp DEFAULT now() NOT NULL,
177 CONSTRAINT "pr_reviews_pr_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests" ("id") ON DELETE CASCADE,
178 CONSTRAINT "pr_reviews_reviewer_fk" FOREIGN KEY ("reviewer_id") REFERENCES "users" ("id")
179);
180
181--> statement-breakpoint
182CREATE INDEX IF NOT EXISTS "pr_reviews_pr" ON "pr_reviews" ("pull_request_id");
183
184--> statement-breakpoint
185CREATE TABLE IF NOT EXISTS "code_owners" (
186 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
187 "repository_id" uuid NOT NULL,
188 "path_pattern" text NOT NULL,
189 "owner_usernames" text NOT NULL,
190 "created_at" timestamp DEFAULT now() NOT NULL,
191 CONSTRAINT "code_owners_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
192);
193
194--> statement-breakpoint
195CREATE INDEX IF NOT EXISTS "code_owners_repo" ON "code_owners" ("repository_id");
196
197--> statement-breakpoint
198CREATE TABLE IF NOT EXISTS "ai_chats" (
199 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
200 "user_id" uuid NOT NULL,
201 "repository_id" uuid,
202 "title" text,
203 "messages" text DEFAULT '[]' NOT NULL,
204 "created_at" timestamp DEFAULT now() NOT NULL,
205 "updated_at" timestamp DEFAULT now() NOT NULL,
206 CONSTRAINT "ai_chats_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
207 CONSTRAINT "ai_chats_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
208);
209
210--> statement-breakpoint
211CREATE INDEX IF NOT EXISTS "ai_chats_user" ON "ai_chats" ("user_id");
212
213--> statement-breakpoint
214CREATE INDEX IF NOT EXISTS "ai_chats_repo" ON "ai_chats" ("repository_id");
215
216--> statement-breakpoint
217CREATE TABLE IF NOT EXISTS "audit_log" (
218 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
219 "user_id" uuid,
220 "repository_id" uuid,
221 "action" text NOT NULL,
222 "target_type" text,
223 "target_id" text,
224 "ip" text,
225 "user_agent" text,
226 "metadata" text,
227 "created_at" timestamp DEFAULT now() NOT NULL,
228 CONSTRAINT "audit_log_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE SET NULL,
229 CONSTRAINT "audit_log_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE SET NULL
230);
231
232--> statement-breakpoint
233CREATE INDEX IF NOT EXISTS "audit_log_user" ON "audit_log" ("user_id");
234
235--> statement-breakpoint
236CREATE INDEX IF NOT EXISTS "audit_log_repo" ON "audit_log" ("repository_id");
237
238--> statement-breakpoint
239CREATE INDEX IF NOT EXISTS "audit_log_created" ON "audit_log" ("created_at");
240
241--> statement-breakpoint
242CREATE TABLE IF NOT EXISTS "deployments" (
243 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
244 "repository_id" uuid NOT NULL,
245 "environment" text DEFAULT 'production' NOT NULL,
246 "commit_sha" text NOT NULL,
247 "ref" text NOT NULL,
248 "status" text NOT NULL,
249 "blocked_reason" text,
250 "target" text,
251 "triggered_by" uuid,
252 "created_at" timestamp DEFAULT now() NOT NULL,
253 "completed_at" timestamp,
254 CONSTRAINT "deployments_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
255 CONSTRAINT "deployments_user_fk" FOREIGN KEY ("triggered_by") REFERENCES "users" ("id")
256);
257
258--> statement-breakpoint
259CREATE INDEX IF NOT EXISTS "deployments_repo" ON "deployments" ("repository_id");
260
261--> statement-breakpoint
262CREATE INDEX IF NOT EXISTS "deployments_created" ON "deployments" ("created_at");
263
264--> statement-breakpoint
265CREATE TABLE IF NOT EXISTS "rate_limit_buckets" (
266 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
267 "bucket_key" text NOT NULL UNIQUE,
268 "count" integer DEFAULT 0 NOT NULL,
269 "window_start" timestamp DEFAULT now() NOT NULL,
270 "expires_at" timestamp NOT NULL
271);
272
273--> statement-breakpoint
274CREATE INDEX IF NOT EXISTS "rate_limit_expires" ON "rate_limit_buckets" ("expires_at");
Addedsrc/__tests__/green-ecosystem.test.ts+158−0View fileUnifiedSplit
@@ -0,0 +1,158 @@
1/**
2 * Tests for the green ecosystem: secret scanner, codeowners parser,
3 * auto-repair helpers, notification + audit log helpers, health routes,
4 * and rate limiting.
5 *
6 * These unit-level tests avoid DB + git subprocess work so they run fast.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import { scanForSecrets, SECRET_PATTERNS } from "../lib/security-scan";
12import {
13 parseCodeowners,
14 ownersForPath,
15} from "../lib/codeowners";
16import { generateCommitMessage } from "../lib/ai-generators";
17import { isAiAvailable } from "../lib/ai-client";
18
19describe("secret scanner", () => {
20 it("detects AWS access keys", () => {
21 const findings = scanForSecrets([
22 {
23 path: "config.env",
24 content: "AWS_ACCESS_KEY=AKIAZ2J4NPQR5LTMWXYZ\n",
25 },
26 ]);
27 expect(findings.length).toBeGreaterThan(0);
28 expect(findings.some((f) => /AWS/i.test(f.type))).toBe(true);
29 });
30
31 it("detects Anthropic API keys", () => {
32 const findings = scanForSecrets([
33 {
34 path: "app.ts",
35 content:
36 'const key = "sk-ant-api03-QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ-AAAAAA";',
37 },
38 ]);
39 expect(findings.some((f) => /anthropic/i.test(f.type))).toBe(true);
40 });
41
42 it("ignores binary/lock paths", () => {
43 const findings = scanForSecrets([
44 {
45 path: "package-lock.json",
46 content: "AKIAZ2J4NPQR5LTMWXYZ secret content",
47 },
48 ]);
49 expect(findings.length).toBe(0);
50 });
51
52 it("does not match placeholder strings in test fixtures", () => {
53 const findings = scanForSecrets([
54 {
55 path: "test.js",
56 content:
57 '// example: AKIA" + "XAMPLE_PLACEHOLDER_KEY_FIXTURE"\nconst k = "FAKE_TEST_PLACEHOLDER";',
58 },
59 ]);
60 // all findings should be filtered by the placeholder heuristic
61 expect(findings.every((f) => !/placeholder|fixture/i.test(f.snippet))).toBe(true);
62 });
63
64 it("has a rich library of rules", () => {
65 expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(10);
66 });
67});
68
69describe("codeowners parser", () => {
70 it("parses simple rules", () => {
71 const rules = parseCodeowners(
72 "# top-level owner\n* @alice\nsrc/api/** @bob @carol\n/docs/ @alice\n"
73 );
74 expect(rules.length).toBe(3);
75 expect(rules[0].owners).toEqual(["alice"]);
76 expect(rules[1].owners).toEqual(["bob", "carol"]);
77 });
78
79 it("resolves last-matching rule wins", () => {
80 const rules = parseCodeowners("* @alice\nsrc/api/** @bob\n");
81 expect(ownersForPath("README.md", rules)).toEqual(["alice"]);
82 expect(ownersForPath("src/api/users.ts", rules)).toEqual(["bob"]);
83 });
84
85 it("anchors leading-slash patterns to repo root", () => {
86 const rules = parseCodeowners("/docs/ @alice\n");
87 expect(ownersForPath("docs/readme.md", rules)).toEqual(["alice"]);
88 expect(ownersForPath("src/docs/readme.md", rules)).toEqual([]);
89 });
90
91 it("ignores comments and blank lines", () => {
92 const rules = parseCodeowners(
93 "# comment\n\n \n# another\n* @ghost # trailing comment\n"
94 );
95 expect(rules.length).toBe(1);
96 expect(rules[0].owners).toEqual(["ghost"]);
97 });
98});
99
100describe("AI generator fallbacks", () => {
101 it("returns a safe fallback commit message when AI is unavailable", async () => {
102 if (isAiAvailable()) {
103 // API key is set — skip fallback assertion
104 return;
105 }
106 const msg = await generateCommitMessage("");
107 expect(msg.length).toBeGreaterThan(0);
108 expect(msg).toMatch(/^\S+/);
109 });
110});
111
112describe("health + metrics endpoints", () => {
113 it("GET /healthz returns 200", async () => {
114 const res = await app.request("/healthz");
115 expect(res.status).toBe(200);
116 const body = await res.json();
117 expect(body.ok).toBe(true);
118 });
119
120 it("GET /metrics returns process metrics", async () => {
121 const res = await app.request("/metrics");
122 expect(res.status).toBe(200);
123 const body = await res.json();
124 expect(typeof body.uptimeMs).toBe("number");
125 expect(typeof body.heapUsed).toBe("number");
126 });
127
128 it("response carries X-Request-Id header", async () => {
129 const res = await app.request("/healthz");
130 expect(res.headers.get("X-Request-Id")).toBeTruthy();
131 });
132});
133
134describe("rate limiting", () => {
135 it("rate-limit headers appear on /api requests", async () => {
136 const res = await app.request("/api/users/nonexistent/repos");
137 // Headers should exist even though user is missing
138 const limit = res.headers.get("X-RateLimit-Limit");
139 expect(limit).toBeTruthy();
140 });
141});
142
143describe("shortcuts + search page", () => {
144 it("GET /shortcuts is public", async () => {
145 const res = await app.request("/shortcuts");
146 expect(res.status).toBe(200);
147 const html = await res.text();
148 expect(html).toContain("Keyboard shortcuts");
149 });
150
151 it("GET /search with no query shows the type tabs", async () => {
152 const res = await app.request("/search");
153 expect(res.status).toBe(200);
154 const html = await res.text();
155 expect(html).toContain("Repositories");
156 expect(html).toContain("Users");
157 });
158});
Modifiedsrc/app.tsx+40−0View fileUnifiedSplit
@@ -3,6 +3,8 @@ import { logger } from "hono/logger";
33import { cors } from "hono/cors";
44import { compress } from "hono/compress";
55import { Layout } from "./views/layout";
6import { requestContext } from "./middleware/request-context";
7import { rateLimit } from "./middleware/rate-limit";
68import gitRoutes from "./routes/git";
79import apiRoutes from "./routes/api";
810import authRoutes from "./routes/auth";
@@ -17,10 +19,20 @@ import webhookRoutes from "./routes/webhooks";
1719import exploreRoutes from "./routes/explore";
1820import tokenRoutes from "./routes/tokens";
1921import contributorRoutes from "./routes/contributors";
22import notificationRoutes from "./routes/notifications";
23import dashboardRoutes from "./routes/dashboard";
24import askRoutes from "./routes/ask";
25import releaseRoutes from "./routes/releases";
26import gateRoutes from "./routes/gates";
27import insightsRoutes from "./routes/insights";
28import searchRoutes from "./routes/search";
29import healthRoutes from "./routes/health";
2030import webRoutes from "./routes/web";
2131
2232const app = new Hono();
2333
34// Request context (request ID, start time) runs before everything else
35app.use("*", requestContext);
2436// Middleware — compression first (wraps all responses)
2537app.use("*", compress());
2638// Logger only on non-git routes to avoid overhead on clone/push
@@ -29,10 +41,17 @@ app.use("*", async (c, next) => {
2941 return logger()(c, next);
3042});
3143app.use("/api/*", cors());
44// Rate-limit API + auth endpoints (generous default)
45app.use("/api/*", rateLimit({ windowMs: 60_000, max: 120 }));
46app.use("/login", rateLimit({ windowMs: 60_000, max: 20 }));
47app.use("/register", rateLimit({ windowMs: 60_000, max: 10 }));
3248
3349// Git Smart HTTP protocol routes (must be before web routes)
3450app.route("/", gitRoutes);
3551
52// Health + metrics
53app.route("/", healthRoutes);
54
3655// REST API
3756app.route("/", apiRoutes);
3857
@@ -45,6 +64,18 @@ app.route("/", settingsRoutes);
4564// API tokens
4665app.route("/", tokenRoutes);
4766
67// Notifications inbox
68app.route("/", notificationRoutes);
69
70// Dashboard (/dashboard)
71app.route("/", dashboardRoutes);
72
73// AI assistant — /ask + /:owner/:repo/ask
74app.route("/", askRoutes);
75
76// Global search
77app.route("/", searchRoutes);
78
4879// Repo settings (description, visibility, delete)
4980app.route("/", repoSettings);
5081
@@ -69,6 +100,15 @@ app.route("/", editorRoutes);
69100// Contributors
70101app.route("/", contributorRoutes);
71102
103// Releases
104app.route("/", releaseRoutes);
105
106// Gates (history + settings + branch protection)
107app.route("/", gateRoutes);
108
109// Insights + milestones
110app.route("/", insightsRoutes);
111
72112// Explore page
73113app.route("/", exploreRoutes);
74114
Modifiedsrc/db/migrate.ts+83−6View fileUnifiedSplit
@@ -1,14 +1,91 @@
1/**
2 * Migration runner — executes every `drizzle/*.sql` file in order.
3 * Tracks applied migrations in a dedicated table so repeat runs are idempotent.
4 */
5
6import { readdir, readFile } from "fs/promises";
7import { join } from "path";
18import { neon } from "@neondatabase/serverless";
2import { drizzle } from "drizzle-orm/neon-http";
3import { migrate } from "drizzle-orm/neon-http/migrator";
49import { config } from "../lib/config";
510
611async function runMigrations() {
12 if (!config.databaseUrl) {
13 throw new Error("DATABASE_URL is not set");
14 }
715 const sql = neon(config.databaseUrl);
8 const db = drizzle(sql);
9 console.log("Running migrations...");
10 await migrate(db, { migrationsFolder: "./drizzle" });
11 console.log("Migrations complete.");
16
17 // Ensure tracking table exists
18 await sql`
19 CREATE TABLE IF NOT EXISTS "_migrations" (
20 "name" text PRIMARY KEY,
21 "applied_at" timestamp DEFAULT now() NOT NULL
22 )
23 `;
24
25 const applied = await sql`SELECT name FROM _migrations`;
26 const appliedSet = new Set(
27 (applied as Array<{ name: string }>).map((r) => r.name)
28 );
29
30 const migrationsDir = join(process.cwd(), "drizzle");
31 const files = (await readdir(migrationsDir))
32 .filter((f) => f.endsWith(".sql"))
33 .sort();
34
35 if (files.length === 0) {
36 console.log("No migration files found.");
37 return;
38 }
39
40 for (const file of files) {
41 if (appliedSet.has(file)) {
42 console.log(`[migrate] ${file} — already applied, skipping`);
43 continue;
44 }
45
46 console.log(`[migrate] applying ${file}...`);
47 const content = await readFile(join(migrationsDir, file), "utf8");
48
49 // Split on the drizzle breakpoint marker. Each chunk is one statement.
50 const statements = content
51 .split(/-->\s*statement-breakpoint/)
52 .map((s) => s.trim())
53 .filter((s) => s.length > 0);
54
55 for (const raw of statements) {
56 const stripped = raw
57 .split("\n")
58 .filter((line) => !line.trim().startsWith("--"))
59 .join("\n")
60 .trim();
61 if (!stripped) continue;
62
63 try {
64 await sql(stripped);
65 } catch (err) {
66 const msg = err instanceof Error ? err.message : String(err);
67 if (
68 msg.includes("already exists") ||
69 msg.includes("duplicate_column") ||
70 msg.includes("duplicate_object")
71 ) {
72 console.warn(
73 `[migrate] ${file}: ${msg.slice(0, 120)} (treated as applied)`
74 );
75 continue;
76 }
77 console.error(
78 `[migrate] ${file} failed on statement:\n${stripped.slice(0, 500)}\n\n${msg}`
79 );
80 throw err;
81 }
82 }
83
84 await sql`INSERT INTO _migrations (name) VALUES (${file})`;
85 console.log(`[migrate] ${file} applied`);
86 }
87
88 console.log("[migrate] all migrations complete");
1289}
1390
1491runMigrations().catch((err) => {
Modifiedsrc/db/schema.ts+348−0View fileUnifiedSplit
@@ -42,6 +42,7 @@ export const repositories = pgTable(
4242 .references(() => users.id),
4343 description: text("description"),
4444 isPrivate: boolean("is_private").default(false).notNull(),
45 isArchived: boolean("is_archived").default(false).notNull(),
4546 defaultBranch: text("default_branch").default("main").notNull(),
4647 diskPath: text("disk_path").notNull(),
4748 forkedFromId: uuid("forked_from_id").references(() => repositories.id, {
@@ -57,6 +58,338 @@ export const repositories = pgTable(
5758 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
5859);
5960
61/**
62 * Per-repository gate + auto-repair configuration.
63 * Every new repo is created with all gates ENABLED by default —
64 * the "full green ecosystem" default. Owners can manually opt-out per setting.
65 */
66export const repoSettings = pgTable("repo_settings", {
67 id: uuid("id").primaryKey().defaultRandom(),
68 repositoryId: uuid("repository_id")
69 .notNull()
70 .unique()
71 .references(() => repositories.id, { onDelete: "cascade" }),
72 // Gates
73 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
74 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
75 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
76 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
77 dependencyScanEnabled: boolean("dependency_scan_enabled").default(true).notNull(),
78 lintEnabled: boolean("lint_enabled").default(true).notNull(),
79 typeCheckEnabled: boolean("type_check_enabled").default(true).notNull(),
80 testEnabled: boolean("test_enabled").default(true).notNull(),
81 // Auto-repair
82 autoFixEnabled: boolean("auto_fix_enabled").default(true).notNull(),
83 autoMergeResolveEnabled: boolean("auto_merge_resolve_enabled").default(true).notNull(),
84 autoFormatEnabled: boolean("auto_format_enabled").default(true).notNull(),
85 // AI features
86 aiCommitMessagesEnabled: boolean("ai_commit_messages_enabled").default(true).notNull(),
87 aiPrSummaryEnabled: boolean("ai_pr_summary_enabled").default(true).notNull(),
88 aiChangelogEnabled: boolean("ai_changelog_enabled").default(true).notNull(),
89 // Deploy
90 autoDeployEnabled: boolean("auto_deploy_enabled").default(true).notNull(),
91 deployRequireAllGreen: boolean("deploy_require_all_green").default(true).notNull(),
92 createdAt: timestamp("created_at").defaultNow().notNull(),
93 updatedAt: timestamp("updated_at").defaultNow().notNull(),
94});
95
96/**
97 * Branch protection rules — enforced on push and merge.
98 * Every repo's default branch gets a protection rule on creation.
99 */
100export const branchProtection = pgTable(
101 "branch_protection",
102 {
103 id: uuid("id").primaryKey().defaultRandom(),
104 repositoryId: uuid("repository_id")
105 .notNull()
106 .references(() => repositories.id, { onDelete: "cascade" }),
107 pattern: text("pattern").notNull(), // branch name or glob (e.g. "main", "release/*")
108 requirePullRequest: boolean("require_pull_request").default(true).notNull(),
109 requireGreenGates: boolean("require_green_gates").default(true).notNull(),
110 requireAiApproval: boolean("require_ai_approval").default(true).notNull(),
111 requireHumanReview: boolean("require_human_review").default(false).notNull(),
112 requiredApprovals: integer("required_approvals").default(0).notNull(),
113 allowForcePush: boolean("allow_force_push").default(false).notNull(),
114 allowDeletion: boolean("allow_deletion").default(false).notNull(),
115 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
116 createdAt: timestamp("created_at").defaultNow().notNull(),
117 updatedAt: timestamp("updated_at").defaultNow().notNull(),
118 },
119 (table) => [
120 uniqueIndex("branch_protection_repo_pattern").on(
121 table.repositoryId,
122 table.pattern
123 ),
124 ]
125);
126
127/**
128 * Gate run history. Every push + every PR creates gate_runs entries —
129 * one per configured gate. Serves as the source of truth for "is this green?".
130 */
131export const gateRuns = pgTable(
132 "gate_runs",
133 {
134 id: uuid("id").primaryKey().defaultRandom(),
135 repositoryId: uuid("repository_id")
136 .notNull()
137 .references(() => repositories.id, { onDelete: "cascade" }),
138 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
139 onDelete: "cascade",
140 }),
141 commitSha: text("commit_sha").notNull(),
142 ref: text("ref").notNull(),
143 gateName: text("gate_name").notNull(), // e.g. "GateTest", "AI Review", "Secret Scan", "Type Check"
144 status: text("status").notNull(), // pending, running, passed, failed, skipped, repaired
145 summary: text("summary"),
146 details: text("details"), // JSON: per-check output, affected files, etc
147 repairAttempted: boolean("repair_attempted").default(false).notNull(),
148 repairSucceeded: boolean("repair_succeeded").default(false).notNull(),
149 repairCommitSha: text("repair_commit_sha"),
150 durationMs: integer("duration_ms"),
151 createdAt: timestamp("created_at").defaultNow().notNull(),
152 completedAt: timestamp("completed_at"),
153 },
154 (table) => [
155 index("gate_runs_repo_sha").on(table.repositoryId, table.commitSha),
156 index("gate_runs_pr").on(table.pullRequestId),
157 index("gate_runs_created").on(table.createdAt),
158 ]
159);
160
161/**
162 * In-app notifications. Powered by the activity feed + explicit mentions.
163 */
164export const notifications = pgTable(
165 "notifications",
166 {
167 id: uuid("id").primaryKey().defaultRandom(),
168 userId: uuid("user_id")
169 .notNull()
170 .references(() => users.id, { onDelete: "cascade" }),
171 repositoryId: uuid("repository_id").references(() => repositories.id, {
172 onDelete: "cascade",
173 }),
174 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
175 title: text("title").notNull(),
176 body: text("body"),
177 url: text("url"), // link to the relevant page
178 readAt: timestamp("read_at"),
179 createdAt: timestamp("created_at").defaultNow().notNull(),
180 },
181 (table) => [
182 index("notifications_user_unread").on(table.userId, table.readAt),
183 index("notifications_user_created").on(table.userId, table.createdAt),
184 ]
185);
186
187/**
188 * Releases — named snapshots of a repo at a tag/commit.
189 * AI-generated changelogs bundled in notes field.
190 */
191export const releases = pgTable(
192 "releases",
193 {
194 id: uuid("id").primaryKey().defaultRandom(),
195 repositoryId: uuid("repository_id")
196 .notNull()
197 .references(() => repositories.id, { onDelete: "cascade" }),
198 authorId: uuid("author_id")
199 .notNull()
200 .references(() => users.id),
201 tag: text("tag").notNull(),
202 name: text("name").notNull(),
203 body: text("body"), // AI-generated release notes + changelog
204 targetCommit: text("target_commit").notNull(),
205 isDraft: boolean("is_draft").default(false).notNull(),
206 isPrerelease: boolean("is_prerelease").default(false).notNull(),
207 createdAt: timestamp("created_at").defaultNow().notNull(),
208 publishedAt: timestamp("published_at"),
209 },
210 (table) => [
211 uniqueIndex("releases_repo_tag").on(table.repositoryId, table.tag),
212 ]
213);
214
215/**
216 * Milestones — group issues + PRs toward a shared goal.
217 */
218export const milestones = pgTable(
219 "milestones",
220 {
221 id: uuid("id").primaryKey().defaultRandom(),
222 repositoryId: uuid("repository_id")
223 .notNull()
224 .references(() => repositories.id, { onDelete: "cascade" }),
225 title: text("title").notNull(),
226 description: text("description"),
227 state: text("state").notNull().default("open"), // open, closed
228 dueDate: timestamp("due_date"),
229 createdAt: timestamp("created_at").defaultNow().notNull(),
230 closedAt: timestamp("closed_at"),
231 },
232 (table) => [index("milestones_repo_state").on(table.repositoryId, table.state)]
233);
234
235/**
236 * Reactions on issues, PRs, and comments. Universal target pointer.
237 */
238export const reactions = pgTable(
239 "reactions",
240 {
241 id: uuid("id").primaryKey().defaultRandom(),
242 userId: uuid("user_id")
243 .notNull()
244 .references(() => users.id, { onDelete: "cascade" }),
245 targetType: text("target_type").notNull(), // issue, pr, issue_comment, pr_comment
246 targetId: uuid("target_id").notNull(),
247 emoji: text("emoji").notNull(), // thumbs_up, thumbs_down, rocket, heart, eyes, laugh, hooray, confused
248 createdAt: timestamp("created_at").defaultNow().notNull(),
249 },
250 (table) => [
251 uniqueIndex("reactions_unique").on(
252 table.userId,
253 table.targetType,
254 table.targetId,
255 table.emoji
256 ),
257 index("reactions_target").on(table.targetType, table.targetId),
258 ]
259);
260
261/**
262 * PR reviews (formal approve/request-changes).
263 * Separate from inline comments in pr_comments.
264 */
265export const prReviews = pgTable(
266 "pr_reviews",
267 {
268 id: uuid("id").primaryKey().defaultRandom(),
269 pullRequestId: uuid("pull_request_id")
270 .notNull()
271 .references(() => pullRequests.id, { onDelete: "cascade" }),
272 reviewerId: uuid("reviewer_id")
273 .notNull()
274 .references(() => users.id),
275 state: text("state").notNull(), // approved, changes_requested, commented
276 body: text("body"),
277 isAi: boolean("is_ai").default(false).notNull(),
278 createdAt: timestamp("created_at").defaultNow().notNull(),
279 },
280 (table) => [index("pr_reviews_pr").on(table.pullRequestId)]
281);
282
283/**
284 * Code owners — who owns which paths (auto-request review on PR).
285 * Parsed from a CODEOWNERS file at the root of the default branch.
286 */
287export const codeOwners = pgTable(
288 "code_owners",
289 {
290 id: uuid("id").primaryKey().defaultRandom(),
291 repositoryId: uuid("repository_id")
292 .notNull()
293 .references(() => repositories.id, { onDelete: "cascade" }),
294 pathPattern: text("path_pattern").notNull(),
295 ownerUsernames: text("owner_usernames").notNull(), // comma-separated
296 createdAt: timestamp("created_at").defaultNow().notNull(),
297 },
298 (table) => [index("code_owners_repo").on(table.repositoryId)]
299);
300
301/**
302 * Per-repo AI chat sessions — conversational repo assistant.
303 */
304export const aiChats = pgTable(
305 "ai_chats",
306 {
307 id: uuid("id").primaryKey().defaultRandom(),
308 userId: uuid("user_id")
309 .notNull()
310 .references(() => users.id, { onDelete: "cascade" }),
311 repositoryId: uuid("repository_id").references(() => repositories.id, {
312 onDelete: "cascade",
313 }),
314 title: text("title"),
315 messages: text("messages").notNull().default("[]"), // JSON array of {role, content}
316 createdAt: timestamp("created_at").defaultNow().notNull(),
317 updatedAt: timestamp("updated_at").defaultNow().notNull(),
318 },
319 (table) => [
320 index("ai_chats_user").on(table.userId),
321 index("ai_chats_repo").on(table.repositoryId),
322 ]
323);
324
325/**
326 * Audit log — every sensitive action. Who did what, when, from where.
327 */
328export const auditLog = pgTable(
329 "audit_log",
330 {
331 id: uuid("id").primaryKey().defaultRandom(),
332 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
333 repositoryId: uuid("repository_id").references(() => repositories.id, {
334 onDelete: "set null",
335 }),
336 action: text("action").notNull(), // repo.create, repo.delete, repo.transfer, token.create, token.revoke, merge, force_push, branch_protection.update, deploy, ...
337 targetType: text("target_type"),
338 targetId: text("target_id"),
339 ip: text("ip"),
340 userAgent: text("user_agent"),
341 metadata: text("metadata"), // JSON for extra context
342 createdAt: timestamp("created_at").defaultNow().notNull(),
343 },
344 (table) => [
345 index("audit_log_user").on(table.userId),
346 index("audit_log_repo").on(table.repositoryId),
347 index("audit_log_created").on(table.createdAt),
348 ]
349);
350
351/**
352 * Deployments — tracks every deploy to downstream systems (Crontech, etc).
353 * Each deploy is gated on ALL green gates passing.
354 */
355export const deployments = pgTable(
356 "deployments",
357 {
358 id: uuid("id").primaryKey().defaultRandom(),
359 repositoryId: uuid("repository_id")
360 .notNull()
361 .references(() => repositories.id, { onDelete: "cascade" }),
362 environment: text("environment").notNull().default("production"),
363 commitSha: text("commit_sha").notNull(),
364 ref: text("ref").notNull(),
365 status: text("status").notNull(), // pending, running, success, failed, blocked
366 blockedReason: text("blocked_reason"),
367 target: text("target"), // e.g. "crontech", "fly.io"
368 triggeredBy: uuid("triggered_by").references(() => users.id),
369 createdAt: timestamp("created_at").defaultNow().notNull(),
370 completedAt: timestamp("completed_at"),
371 },
372 (table) => [
373 index("deployments_repo").on(table.repositoryId),
374 index("deployments_created").on(table.createdAt),
375 ]
376);
377
378/**
379 * Rate-limit buckets — in-memory or persisted counter per IP / token / route.
380 */
381export const rateLimitBuckets = pgTable(
382 "rate_limit_buckets",
383 {
384 id: uuid("id").primaryKey().defaultRandom(),
385 bucketKey: text("bucket_key").notNull().unique(), // "ip:1.2.3.4:api" or "token:abc:api"
386 count: integer("count").default(0).notNull(),
387 windowStart: timestamp("window_start").defaultNow().notNull(),
388 expiresAt: timestamp("expires_at").notNull(),
389 },
390 (table) => [index("rate_limit_expires").on(table.expiresAt)]
391);
392
60393export const stars = pgTable(
61394 "stars",
62395 {
@@ -163,6 +496,9 @@ export const pullRequests = pgTable(
163496 state: text("state").notNull().default("open"), // open, closed, merged
164497 baseBranch: text("base_branch").notNull(),
165498 headBranch: text("head_branch").notNull(),
499 isDraft: boolean("is_draft").default(false).notNull(),
500 mergeStrategy: text("merge_strategy").default("merge").notNull(), // merge, squash, rebase
501 milestoneId: uuid("milestone_id"),
166502 mergedAt: timestamp("merged_at"),
167503 mergedBy: uuid("merged_by").references(() => users.id),
168504 createdAt: timestamp("created_at").defaultNow().notNull(),
@@ -290,3 +626,15 @@ export type ActivityEntry = typeof activityFeed.$inferSelect;
290626export type Webhook = typeof webhooks.$inferSelect;
291627export type ApiToken = typeof apiTokens.$inferSelect;
292628export type RepoTopic = typeof repoTopics.$inferSelect;
629export type RepoSettings = typeof repoSettings.$inferSelect;
630export type BranchProtection = typeof branchProtection.$inferSelect;
631export type GateRun = typeof gateRuns.$inferSelect;
632export type Notification = typeof notifications.$inferSelect;
633export type Release = typeof releases.$inferSelect;
634export type Milestone = typeof milestones.$inferSelect;
635export type Reaction = typeof reactions.$inferSelect;
636export type PrReview = typeof prReviews.$inferSelect;
637export type CodeOwner = typeof codeOwners.$inferSelect;
638export type AiChat = typeof aiChats.$inferSelect;
639export type AuditLogEntry = typeof auditLog.$inferSelect;
640export type Deployment = typeof deployments.$inferSelect;
Modifiedsrc/git/repository.ts+95−0View fileUnifiedSplit
@@ -128,6 +128,101 @@ export async function resolveRef(
128128 return stdout.trim();
129129}
130130
131/**
132 * List all tags (newest first by commit date).
133 * Returns array of { name, sha, date }.
134 */
135export async function listTags(
136 owner: string,
137 name: string
138): Promise<Array<{ name: string; sha: string; date: string }>> {
139 const path = repoPath(owner, name);
140 const { stdout, exitCode } = await exec(
141 [
142 "git",
143 "for-each-ref",
144 "--sort=-creatordate",
145 "--format=%(refname:short)%00%(objectname)%00%(creatordate:iso-strict)",
146 "refs/tags/",
147 ],
148 { cwd: path }
149 );
150 if (exitCode !== 0) return [];
151 return stdout
152 .trim()
153 .split("\n")
154 .filter(Boolean)
155 .map((line) => {
156 const [tname, sha, date] = line.split("\0");
157 return { name: tname, sha, date };
158 });
159}
160
161/**
162 * Create a lightweight git tag pointing at a commit.
163 */
164export async function createTag(
165 owner: string,
166 name: string,
167 tag: string,
168 sha: string,
169 annotation?: string
170): Promise<boolean> {
171 const path = repoPath(owner, name);
172 const args = annotation
173 ? ["git", "tag", "-a", tag, sha, "-m", annotation]
174 : ["git", "tag", tag, sha];
175 const { exitCode } = await exec(args, { cwd: path });
176 return exitCode === 0;
177}
178
179/**
180 * Delete a tag.
181 */
182export async function deleteTag(
183 owner: string,
184 name: string,
185 tag: string
186): Promise<boolean> {
187 const path = repoPath(owner, name);
188 const { exitCode } = await exec(["git", "tag", "-d", tag], { cwd: path });
189 return exitCode === 0;
190}
191
192/**
193 * List commits between two refs (excluding `from`, including `to`).
194 */
195export async function commitsBetween(
196 owner: string,
197 name: string,
198 from: string | null,
199 to: string
200): Promise<GitCommit[]> {
201 const path = repoPath(owner, name);
202 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
203 const range = from ? `${from}..${to}` : to;
204 const { stdout, exitCode } = await exec(
205 ["git", "log", `--format=${format}`, "-500", range],
206 { cwd: path }
207 );
208 if (exitCode !== 0) return [];
209 return stdout
210 .trim()
211 .split("\n")
212 .filter(Boolean)
213 .map((line) => {
214 const [sha, message, author, authorEmail, date, parents] = line.split("\0");
215 return {
216 sha,
217 message,
218 author,
219 authorEmail,
220 date,
221 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
222 };
223 });
224}
225
131226export async function getCommit(
132227 owner: string,
133228 name: string,
Modifiedsrc/hooks/post-receive.ts+198−13View fileUnifiedSplit
@@ -1,11 +1,32 @@
11/**
22 * Post-receive hook logic.
3 * Called after a successful git push to trigger GateTest scans
4 * and Crontech deploys.
3 * Runs after a successful git push.
4 *
5 * 1. Update repo.pushedAt and push activity
6 * 2. Sync CODEOWNERS from the default branch
7 * 3. Run gates (GateTest + secret + security) on the new ref
8 * 4. Auto-deploy to Crontech ONLY if gates are green and settings allow it
9 * 5. Fan out webhooks
510 */
611
12import { and, eq } from "drizzle-orm";
713import { config } from "../lib/config";
8import { runGateTestScan } from "../lib/gate";
14import { db } from "../db";
15import {
16 activityFeed,
17 deployments,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import {
23 runGateTestScan,
24 runSecretAndSecurityScan,
25} from "../lib/gate";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getBlob, getDefaultBranch } from "../git/repository";
28import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
29import { notify } from "../lib/notify";
930
1031interface PushRef {
1132 oldSha: string;
@@ -18,11 +39,83 @@ export async function onPostReceive(
1839 repo: string,
1940 refs: PushRef[]
2041): Promise<void> {
21 const promises: Promise<void>[] = [];
42 const [ownerRow] = await db
43 .select()
44 .from(users)
45 .where(eq(users.username, owner))
46 .limit(1);
47 const repoRow = ownerRow
48 ? (
49 await db
50 .select()
51 .from(repositories)
52 .where(
53 and(
54 eq(repositories.ownerId, ownerRow.id),
55 eq(repositories.name, repo)
56 )
57 )
58 .limit(1)
59 )[0]
60 : null;
61
62 const defaultBranch =
63 (await getDefaultBranch(owner, repo)) || repoRow?.defaultBranch || "main";
64
65 // --- 1. pushedAt + activity ---
66 if (repoRow) {
67 try {
68 await db
69 .update(repositories)
70 .set({ pushedAt: new Date(), updatedAt: new Date() })
71 .where(eq(repositories.id, repoRow.id));
72 for (const ref of refs) {
73 if (!ref.newSha.startsWith("0000")) {
74 await db.insert(activityFeed).values({
75 repositoryId: repoRow.id,
76 userId: ownerRow?.id || null,
77 action: "push",
78 targetType: "commit",
79 targetId: ref.newSha,
80 metadata: JSON.stringify({ ref: ref.refName }),
81 });
82 }
83 }
84 } catch (err) {
85 console.error("[post-receive] activity/pushedAt:", err);
86 }
87 }
88
89 // --- 2. CODEOWNERS sync (only when default branch changed) ---
90 const mainRef = refs.find(
91 (r) =>
92 r.refName === `refs/heads/${defaultBranch}` &&
93 !r.newSha.startsWith("0000")
94 );
95 if (mainRef && repoRow) {
96 try {
97 const paths = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
98 for (const p of paths) {
99 const blob = await getBlob(owner, repo, defaultBranch, p);
100 if (blob && !blob.isBinary) {
101 const rules = parseCodeowners(blob.content);
102 await syncCodeowners(repoRow.id, rules);
103 break;
104 }
105 }
106 } catch (err) {
107 console.error("[post-receive] codeowners sync:", err);
108 }
109 }
110
111 // --- 3. Gates ---
112 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
22113
23 // GateTest scan on every push (non-blocking, results stored for merge gating)
114 const promises: Promise<void>[] = [];
24115 for (const ref of refs) {
25 if (!ref.newSha.startsWith("0000")) {
116 if (ref.newSha.startsWith("0000")) continue;
117
118 if (settings?.gateTestEnabled !== false) {
26119 promises.push(
27120 runGateTestScan(owner, repo, ref.refName, ref.newSha)
28121 .then((result) => {
@@ -35,14 +128,47 @@ export async function onPostReceive(
35128 })
36129 );
37130 }
131
132 if (
133 settings?.secretScanEnabled !== false ||
134 settings?.securityScanEnabled !== false
135 ) {
136 promises.push(
137 runSecretAndSecurityScan(owner, repo, ref.refName, ref.newSha, {
138 scanSecrets: settings?.secretScanEnabled !== false,
139 scanSecurity: false, // semantic scan needs a diff — deferred to PR gate
140 })
141 .then((result) => {
142 if (
143 !result.secretResult.passed &&
144 ownerRow &&
145 repoRow &&
146 result.secrets.length > 0
147 ) {
148 void notify(ownerRow.id, {
149 kind: "security_alert",
150 title: `Secret detected in ${owner}/${repo}`,
151 body: result.secretResult.details,
152 url: `/${owner}/${repo}/gates`,
153 repositoryId: repoRow.id,
154 });
155 }
156 })
157 .catch((err) => {
158 console.error(`[secret-scan] error for ${owner}/${repo}:`, err);
159 })
160 );
161 }
38162 }
39163
40 // Crontech deploy on push to main (only if GateTest passes)
41 const mainPush = refs.find(
42 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
43 );
44 if (mainPush) {
45 promises.push(triggerCrontechDeploy(owner, repo, mainPush.newSha));
164 // --- 4. Auto-deploy (only on default branch + green settings) ---
165 if (mainRef && settings?.autoDeployEnabled !== false && repoRow) {
166 promises.push(triggerCrontechDeploy(owner, repo, mainRef.newSha, repoRow.id));
167 }
168
169 // --- 5. Webhook fan-out ---
170 if (repoRow) {
171 promises.push(fanoutWebhooks(repoRow.id, owner, repo, refs));
46172 }
47173
48174 await Promise.allSettled(promises);
@@ -51,8 +177,27 @@ export async function onPostReceive(
51177async function triggerCrontechDeploy(
52178 owner: string,
53179 repo: string,
54 sha: string
180 sha: string,
181 repositoryId: string
55182): Promise<void> {
183 let deployId = "";
184 try {
185 const [row] = await db
186 .insert(deployments)
187 .values({
188 repositoryId,
189 environment: "production",
190 commitSha: sha,
191 ref: "refs/heads/main",
192 status: "pending",
193 target: "crontech",
194 })
195 .returning();
196 deployId = row?.id || "";
197 } catch {
198 /* ignore */
199 }
200
56201 try {
57202 const response = await fetch(config.crontechDeployUrl, {
58203 method: "POST",
@@ -67,7 +212,47 @@ async function triggerCrontechDeploy(
67212 console.log(
68213 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
69214 );
215 if (deployId) {
216 await db
217 .update(deployments)
218 .set({
219 status: response.ok ? "success" : "failed",
220 completedAt: new Date(),
221 })
222 .where(eq(deployments.id, deployId));
223 }
70224 } catch (err) {
71225 console.error(`[crontech] failed to trigger deploy:`, err);
226 if (deployId) {
227 await db
228 .update(deployments)
229 .set({
230 status: "failed",
231 blockedReason: (err as Error).message,
232 completedAt: new Date(),
233 })
234 .where(eq(deployments.id, deployId));
235 }
236 }
237}
238
239async function fanoutWebhooks(
240 repositoryId: string,
241 owner: string,
242 repo: string,
243 refs: PushRef[]
244): Promise<void> {
245 try {
246 const { fireWebhooks } = await import("../routes/webhooks");
247 await fireWebhooks(repositoryId, "push", {
248 repository: `${owner}/${repo}`,
249 refs: refs.map((r) => ({
250 ref: r.refName,
251 before: r.oldSha,
252 after: r.newSha,
253 })),
254 });
255 } catch {
256 // best-effort
72257 }
73258}
Addedsrc/lib/ai-chat.ts+193−0View fileUnifiedSplit
@@ -0,0 +1,193 @@
1/**
2 * AI chat assistant — conversational interface grounded in repo context.
3 *
4 * The assistant can:
5 * - Answer questions about the codebase ("where is auth handled?")
6 * - Explain files / functions ("explain src/lib/gate.ts")
7 * - Draft code (answered verbally, user applies manually)
8 * - Summarise recent activity ("what changed this week?")
9 *
10 * Grounds its answers in a curated context window built from:
11 * - Repo README
12 * - Recent commits
13 * - Tree listing
14 * - Files the user explicitly @-mentions in the message
15 */
16
17import {
18 getAnthropic,
19 MODEL_SONNET,
20 extractText,
21 isAiAvailable,
22} from "./ai-client";
23import {
24 getReadme,
25 getTree,
26 getBlob,
27 listCommits,
28 getDefaultBranch,
29} from "../git/repository";
30
31export interface ChatMessage {
32 role: "user" | "assistant";
33 content: string;
34}
35
36export interface ChatResponse {
37 reply: string;
38 citedFiles: string[];
39}
40
41/**
42 * Build a concise repo context block.
43 * Keeps under ~60k chars to leave room for conversation.
44 */
45async function buildRepoContext(
46 owner: string,
47 repo: string,
48 mentionedFiles: string[]
49): Promise<{ context: string; files: string[] }> {
50 const branch = (await getDefaultBranch(owner, repo)) || "main";
51 const citedFiles: string[] = [];
52 const parts: string[] = [];
53
54 parts.push(`# Repository: ${owner}/${repo}\nDefault branch: ${branch}\n`);
55
56 // README
57 const readme = await getReadme(owner, repo, branch);
58 if (readme) {
59 parts.push(`## README\n${readme.slice(0, 8000)}\n`);
60 }
61
62 // Top-level tree
63 const tree = await getTree(owner, repo, branch);
64 if (tree.length > 0) {
65 parts.push(
66 `## Top-level files\n${tree
67 .slice(0, 60)
68 .map((e) => `- ${e.type === "tree" ? e.name + "/" : e.name}`)
69 .join("\n")}\n`
70 );
71 }
72
73 // Recent commits
74 const commits = await listCommits(owner, repo, branch, 15);
75 if (commits.length > 0) {
76 parts.push(
77 `## Recent commits\n${commits
78 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
79 .join("\n")}\n`
80 );
81 }
82
83 // Mentioned files
84 for (const file of mentionedFiles.slice(0, 8)) {
85 try {
86 const blob = await getBlob(owner, repo, branch, file);
87 if (blob && !blob.isBinary) {
88 citedFiles.push(file);
89 parts.push(`## File: ${file}\n\`\`\`\n${blob.content.slice(0, 12000)}\n\`\`\`\n`);
90 }
91 } catch {
92 // ignore
93 }
94 }
95
96 return { context: parts.join("\n"), files: citedFiles };
97}
98
99/**
100 * Extract @-mentions of files from a user's message.
101 * Supports @filename.ext and @path/to/file.ext.
102 */
103function extractFileMentions(text: string): string[] {
104 const matches = text.match(/@([A-Za-z0-9._/-]+\.[A-Za-z0-9]+)/g);
105 if (!matches) return [];
106 return Array.from(new Set(matches.map((m) => m.slice(1))));
107}
108
109export async function chat(
110 owner: string,
111 repo: string | null,
112 history: ChatMessage[],
113 userMessage: string
114): Promise<ChatResponse> {
115 if (!isAiAvailable()) {
116 return {
117 reply:
118 "AI chat is not available — the server needs an ANTHROPIC_API_KEY to be configured.",
119 citedFiles: [],
120 };
121 }
122 const client = getAnthropic();
123
124 const mentioned = extractFileMentions(userMessage);
125 const { context: repoContext, files } = repo
126 ? await buildRepoContext(owner, repo, mentioned)
127 : { context: "", files: [] };
128
129 const system = repo
130 ? `You are GlueCron's AI assistant. You help developers understand and work with the repository ${owner}/${repo}. Be concise, accurate, and reference specific files and line numbers when relevant. If the user asks about something not in your context, say so.`
131 : `You are GlueCron's AI assistant. You help developers navigate the GlueCron platform — a git host with green-gate enforcement, AI code review, and auto-repair. Keep answers concise.`;
132
133 const messages: ChatMessage[] = [
134 ...history,
135 {
136 role: "user",
137 content: repoContext
138 ? `${repoContext}\n\n---\n\nUser question: ${userMessage}`
139 : userMessage,
140 },
141 ];
142
143 const response = await client.messages.create({
144 model: MODEL_SONNET,
145 max_tokens: 2048,
146 system,
147 messages: messages.map((m) => ({ role: m.role, content: m.content })),
148 });
149
150 return {
151 reply: extractText(response).trim(),
152 citedFiles: files,
153 };
154}
155
156/**
157 * Explain a single file — used for the "Explain this file" button on blob views.
158 */
159export async function explainFile(
160 owner: string,
161 repo: string,
162 filePath: string,
163 content: string
164): Promise<string> {
165 if (!isAiAvailable()) {
166 return "AI explanations are not available — server needs ANTHROPIC_API_KEY.";
167 }
168 const client = getAnthropic();
169 const message = await client.messages.create({
170 model: MODEL_SONNET,
171 max_tokens: 1024,
172 messages: [
173 {
174 role: "user",
175 content: `Explain this file from ${owner}/${repo} in plain English.
176
177Structure:
1781. **Purpose** — one sentence
1792. **Key exports / APIs** — bulleted list
1803. **How it works** — 2-4 sentences
1814. **Gotchas / caveats** — only if any
182
183Be concise.
184
185File: ${filePath}
186\`\`\`
187${content.slice(0, 40000)}
188\`\`\``,
189 },
190 ],
191 });
192 return extractText(message).trim();
193}
Addedsrc/lib/ai-client.ts+75−0View fileUnifiedSplit
@@ -0,0 +1,75 @@
1/**
2 * Shared Anthropic client + helpers for all AI-powered features.
3 * Centralised here so model choice, caching, and key handling live in one place.
4 */
5
6import Anthropic from "@anthropic-ai/sdk";
7import { config } from "./config";
8
9let _client: Anthropic | null = null;
10
11export function getAnthropic(): Anthropic {
12 if (!_client) {
13 if (!config.anthropicApiKey) {
14 throw new Error("ANTHROPIC_API_KEY is not set");
15 }
16 _client = new Anthropic({ apiKey: config.anthropicApiKey });
17 }
18 return _client;
19}
20
21export function isAiAvailable(): boolean {
22 return !!config.anthropicApiKey;
23}
24
25/** Default model for code understanding + review */
26export const MODEL_SONNET = "claude-sonnet-4-20250514";
27/** Fast model for lightweight tasks (commit messages, titles) */
28export const MODEL_HAIKU = "claude-haiku-4-5-20251001";
29
30/**
31 * Extract text content from an Anthropic message response.
32 */
33export function extractText(
34 message: Anthropic.Messages.Message
35): string {
36 for (const block of message.content) {
37 if (block.type === "text") return block.text;
38 }
39 return "";
40}
41
42/**
43 * Safely parse JSON from a Claude response that may include surrounding prose
44 * or a ```json code block.
45 */
46export function parseJsonResponse<T = unknown>(text: string): T | null {
47 // Prefer ```json blocks
48 const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/);
49 const candidate = fenced ? fenced[1] : null;
50 if (candidate) {
51 try {
52 return JSON.parse(candidate) as T;
53 } catch {
54 // fall through
55 }
56 }
57 // Fall back to first top-level {...} or [...]
58 const braceMatch = text.match(/\{[\s\S]*\}/);
59 if (braceMatch) {
60 try {
61 return JSON.parse(braceMatch[0]) as T;
62 } catch {
63 // fall through
64 }
65 }
66 const bracketMatch = text.match(/\[[\s\S]*\]/);
67 if (bracketMatch) {
68 try {
69 return JSON.parse(bracketMatch[0]) as T;
70 } catch {
71 return null;
72 }
73 }
74 return null;
75}
Addedsrc/lib/ai-generators.ts+170−0View fileUnifiedSplit
@@ -0,0 +1,170 @@
1/**
2 * AI generators — commit messages, PR descriptions, changelogs, issue triage.
3 * All exposed via POST endpoints for CLI hooks + web UI convenience.
4 */
5
6import {
7 getAnthropic,
8 MODEL_HAIKU,
9 MODEL_SONNET,
10 extractText,
11 parseJsonResponse,
12 isAiAvailable,
13} from "./ai-client";
14
15export async function generateCommitMessage(diff: string): Promise<string> {
16 if (!isAiAvailable() || !diff.trim()) {
17 return "chore: update files";
18 }
19 const client = getAnthropic();
20 const message = await client.messages.create({
21 model: MODEL_HAIKU,
22 max_tokens: 512,
23 messages: [
24 {
25 role: "user",
26 content: `Write a single conventional-commit message for this diff. One line subject under 72 chars, lowercase type (feat/fix/refactor/chore/docs/test/perf/style), then optional body wrapped at 72 chars. No backticks or markdown.
27
28Diff:
29\`\`\`
30${diff.slice(0, 40000)}
31\`\`\``,
32 },
33 ],
34 });
35 return extractText(message).trim();
36}
37
38export async function generatePrSummary(
39 title: string,
40 diff: string
41): Promise<string> {
42 if (!isAiAvailable() || !diff.trim()) return "";
43 const client = getAnthropic();
44 const message = await client.messages.create({
45 model: MODEL_SONNET,
46 max_tokens: 1024,
47 messages: [
48 {
49 role: "user",
50 content: `Generate a Markdown PR description for the following changes. Include: Summary (1-3 sentences focused on why), Key changes (bullets), Test plan (bullets), Risks (bullets — omit if minor).
51
52Title: ${title}
53
54Diff:
55\`\`\`
56${diff.slice(0, 60000)}
57\`\`\``,
58 },
59 ],
60 });
61 return extractText(message).trim();
62}
63
64export async function generateChangelog(
65 repoFullName: string,
66 fromRef: string | null,
67 toRef: string,
68 commits: Array<{ sha: string; message: string; author: string }>
69): Promise<string> {
70 if (!isAiAvailable() || commits.length === 0) {
71 const header = `## ${toRef}${fromRef ? ` (since ${fromRef})` : ""}\n\n`;
72 return (
73 header +
74 commits
75 .map((c) => `- ${c.message.split("\n")[0]} (${c.sha.slice(0, 7)}) — ${c.author}`)
76 .join("\n")
77 );
78 }
79 const client = getAnthropic();
80 const commitBlob = commits
81 .slice(0, 200)
82 .map((c) => `- ${c.sha.slice(0, 7)} ${c.message.split("\n")[0]} — ${c.author}`)
83 .join("\n");
84 const message = await client.messages.create({
85 model: MODEL_SONNET,
86 max_tokens: 2048,
87 messages: [
88 {
89 role: "user",
90 content: `Generate a polished release-notes changelog in Markdown for ${repoFullName}.
91
92Release: ${toRef}
93Previous: ${fromRef || "(initial)"}
94
95Group commits by category (Features, Fixes, Performance, Refactoring, Docs, Other). Omit empty categories. Use bullet points. Keep it concise — no marketing fluff, just the facts a user of the project needs. Reference SHAs in parentheses.
96
97Commits:
98${commitBlob}`,
99 },
100 ],
101 });
102 return extractText(message).trim();
103}
104
105interface IssueTriage {
106 suggestedLabels: string[];
107 duplicateOfIssueNumber: number | null;
108 priority: "critical" | "high" | "medium" | "low";
109 summary: string;
110}
111
112export async function triageIssue(
113 title: string,
114 body: string,
115 existingLabels: string[],
116 recentIssues: Array<{ number: number; title: string }>
117): Promise<IssueTriage> {
118 const fallback: IssueTriage = {
119 suggestedLabels: [],
120 duplicateOfIssueNumber: null,
121 priority: "medium",
122 summary: "",
123 };
124 if (!isAiAvailable()) return fallback;
125 const client = getAnthropic();
126 const message = await client.messages.create({
127 model: MODEL_HAIKU,
128 max_tokens: 512,
129 messages: [
130 {
131 role: "user",
132 content: `Triage this new GitHub-style issue.
133
134Title: ${title}
135Body:
136${body.slice(0, 4000)}
137
138Available labels: ${existingLabels.join(", ") || "(none)"}
139Recent issues (to check for duplicates):
140${recentIssues.map((i) => `#${i.number}: ${i.title}`).join("\n")}
141
142Respond ONLY with JSON:
143{
144 "suggestedLabels": ["label1", "label2"],
145 "duplicateOfIssueNumber": null,
146 "priority": "medium",
147 "summary": "one sentence"
148}
149Only suggest labels from the available list. Set duplicateOfIssueNumber only when confident.`,
150 },
151 ],
152 });
153 const parsed = parseJsonResponse<IssueTriage>(extractText(message));
154 if (!parsed) return fallback;
155 return {
156 suggestedLabels: Array.isArray(parsed.suggestedLabels)
157 ? parsed.suggestedLabels.filter((l) => existingLabels.includes(l))
158 : [],
159 duplicateOfIssueNumber:
160 typeof parsed.duplicateOfIssueNumber === "number"
161 ? parsed.duplicateOfIssueNumber
162 : null,
163 priority: (["critical", "high", "medium", "low"] as const).includes(
164 parsed.priority as never
165 )
166 ? parsed.priority
167 : "medium",
168 summary: typeof parsed.summary === "string" ? parsed.summary : "",
169 };
170}
Addedsrc/lib/auto-repair.ts+472−0View fileUnifiedSplit
@@ -0,0 +1,472 @@
1/**
2 * AI-powered auto-repair engine.
3 *
4 * When a gate fails, this engine attempts to automatically fix the problem
5 * and push the fix back to the branch. Covers:
6 * - Failing tests → analyse + patch source
7 * - Type errors → fix type signatures
8 * - Lint errors → apply fixes or reformat
9 * - Secret leaks → redact secret, add to .gitignore, force-push fix
10 * - Security issues → patch vulnerable code
11 *
12 * Works in a temporary worktree so the bare repo is never corrupted.
13 * All repair commits are authored by "GlueCron AI" and recorded in gate_runs.
14 */
15
16import { spawn } from "bun";
17import { mkdir, rm, readFile, writeFile } from "fs/promises";
18import { join } from "path";
19import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
20import { getRepoPath } from "../git/repository";
21import type { SecurityFinding, SecretFinding } from "./security-scan";
22
23export interface RepairResult {
24 attempted: boolean;
25 success: boolean;
26 commitSha?: string;
27 filesChanged: string[];
28 summary: string;
29 error?: string;
30}
31
32async function exec(
33 cmd: string[],
34 opts?: { cwd?: string; env?: Record<string, string> }
35): Promise<{ stdout: string; stderr: string; exitCode: number }> {
36 const proc = spawn(cmd, {
37 cwd: opts?.cwd,
38 env: { ...process.env, ...opts?.env },
39 stdout: "pipe",
40 stderr: "pipe",
41 });
42 const [stdout, stderr] = await Promise.all([
43 new Response(proc.stdout).text(),
44 new Response(proc.stderr).text(),
45 ]);
46 const exitCode = await proc.exited;
47 return { stdout, stderr, exitCode };
48}
49
50const AUTHOR_ENV = {
51 GIT_AUTHOR_NAME: "GlueCron AI",
52 GIT_AUTHOR_EMAIL: "ai@gluecron.com",
53 GIT_COMMITTER_NAME: "GlueCron AI",
54 GIT_COMMITTER_EMAIL: "ai@gluecron.com",
55};
56
57interface Patch {
58 path: string;
59 /** Full replacement content. Preferred for simplicity + correctness. */
60 content: string;
61 /** Short rationale for the change — included in the commit message. */
62 reason: string;
63}
64
65/**
66 * Create a disposable worktree at the given branch head.
67 * Returns the worktree path; caller MUST call cleanupWorktree when done.
68 */
69async function createWorktree(
70 repoDir: string,
71 branch: string
72): Promise<{ path: string; ok: boolean; error?: string }> {
73 const path = join(repoDir, `_repair_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
74 const res = await exec(["git", "worktree", "add", path, branch], { cwd: repoDir });
75 if (res.exitCode !== 0) {
76 return { path, ok: false, error: res.stderr };
77 }
78 return { path, ok: true };
79}
80
81async function cleanupWorktree(repoDir: string, worktree: string): Promise<void> {
82 await exec(["git", "worktree", "remove", "--force", worktree], {
83 cwd: repoDir,
84 }).catch(() => {});
85 await rm(worktree, { recursive: true, force: true }).catch(() => {});
86}
87
88/**
89 * Apply patches to a worktree, commit, and update the branch ref in the bare repo.
90 */
91async function applyAndCommit(
92 repoDir: string,
93 worktree: string,
94 branch: string,
95 patches: Patch[],
96 commitMessage: string
97): Promise<{ ok: boolean; sha?: string; error?: string; filesChanged: string[] }> {
98 const filesChanged: string[] = [];
99 for (const patch of patches) {
100 const fullPath = join(worktree, patch.path);
101 try {
102 await mkdir(join(fullPath, "..").replace(/[^/]+\/\.\.$/, ""), { recursive: true }).catch(() => {});
103 await writeFile(fullPath, patch.content, "utf8");
104 filesChanged.push(patch.path);
105 } catch (err) {
106 console.error(`[auto-repair] Failed to write ${patch.path}:`, err);
107 }
108 }
109 if (filesChanged.length === 0) {
110 return { ok: false, error: "No patches applied", filesChanged: [] };
111 }
112
113 const add = await exec(["git", "add", "-A"], { cwd: worktree });
114 if (add.exitCode !== 0) {
115 return { ok: false, error: `git add: ${add.stderr}`, filesChanged };
116 }
117
118 const commit = await exec(
119 ["git", "commit", "-m", commitMessage],
120 { cwd: worktree, env: AUTHOR_ENV }
121 );
122 if (commit.exitCode !== 0) {
123 return { ok: false, error: `git commit: ${commit.stderr}`, filesChanged };
124 }
125
126 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
127
128 // Push the new commit to the branch ref in the bare repo
129 const push = await exec(
130 ["git", "push", "origin", `HEAD:refs/heads/${branch}`],
131 { cwd: worktree }
132 );
133 if (push.exitCode !== 0) {
134 // Fall back to update-ref on bare repo if "origin" isn't the bare repo
135 const upd = await exec(
136 ["git", "update-ref", `refs/heads/${branch}`, sha.trim()],
137 { cwd: repoDir }
138 );
139 if (upd.exitCode !== 0) {
140 return { ok: false, error: `update-ref: ${upd.stderr}`, filesChanged };
141 }
142 }
143
144 return { ok: true, sha: sha.trim(), filesChanged };
145}
146
147/**
148 * Repair secret leaks by redacting the matching lines.
149 * This is a defensive baseline — the secret itself must be rotated manually
150 * because git history already contains it, but removing it from HEAD prevents
151 * further exposure.
152 */
153export async function repairSecrets(
154 owner: string,
155 repo: string,
156 branch: string,
157 findings: SecretFinding[]
158): Promise<RepairResult> {
159 if (findings.length === 0) {
160 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
161 }
162 const repoDir = getRepoPath(owner, repo);
163 const wt = await createWorktree(repoDir, branch);
164 if (!wt.ok) {
165 return {
166 attempted: true,
167 success: false,
168 filesChanged: [],
169 summary: "could not create worktree",
170 error: wt.error,
171 };
172 }
173
174 try {
175 // Group findings by file
176 const byFile = new Map<string, SecretFinding[]>();
177 for (const f of findings) {
178 if (!byFile.has(f.file)) byFile.set(f.file, []);
179 byFile.get(f.file)!.push(f);
180 }
181
182 const patches: Patch[] = [];
183 for (const [file, fileFindings] of byFile) {
184 try {
185 const content = await readFile(join(wt.path, file), "utf8");
186 const lines = content.split("\n");
187 const badLines = new Set(fileFindings.map((f) => f.line - 1));
188 for (const idx of badLines) {
189 if (idx >= 0 && idx < lines.length) {
190 // Redact everything that looks like a value after = or :
191 lines[idx] = lines[idx].replace(
192 /(['"])[A-Za-z0-9_\-/+=\.]{20,}(['"])/g,
193 '$1REDACTED_BY_GLUECRON$2'
194 );
195 // If the whole line IS the secret (PEM), comment it out
196 if (lines[idx].includes("BEGIN") && lines[idx].includes("PRIVATE KEY")) {
197 lines[idx] = `// ${lines[idx]} // REDACTED_BY_GLUECRON`;
198 }
199 }
200 }
201 patches.push({
202 path: file,
203 content: lines.join("\n"),
204 reason: `Redact ${fileFindings.length} secret${fileFindings.length === 1 ? "" : "s"}`,
205 });
206 } catch (err) {
207 console.error(`[auto-repair] Could not read ${file}:`, err);
208 }
209 }
210
211 if (patches.length === 0) {
212 return {
213 attempted: true,
214 success: false,
215 filesChanged: [],
216 summary: "no files to patch",
217 };
218 }
219
220 const msg = `fix(security): auto-redact leaked secrets
221
222Redacted ${findings.length} secret finding${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}.
223
224ACTION REQUIRED: these credentials must be rotated — they remain visible in git history.
225
226[auto-repair by GlueCron AI]`;
227
228 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
229 if (!result.ok) {
230 return {
231 attempted: true,
232 success: false,
233 filesChanged: result.filesChanged,
234 summary: "commit failed",
235 error: result.error,
236 };
237 }
238 return {
239 attempted: true,
240 success: true,
241 commitSha: result.sha,
242 filesChanged: result.filesChanged,
243 summary: `Redacted ${findings.length} secret${findings.length === 1 ? "" : "s"} across ${patches.length} file${patches.length === 1 ? "" : "s"}`,
244 };
245 } finally {
246 await cleanupWorktree(repoDir, wt.path);
247 }
248}
249
250/**
251 * Use Claude to repair a set of security findings by rewriting affected files.
252 */
253export async function repairSecurityIssues(
254 owner: string,
255 repo: string,
256 branch: string,
257 findings: SecurityFinding[]
258): Promise<RepairResult> {
259 if (findings.length === 0) {
260 return { attempted: false, success: false, filesChanged: [], summary: "no findings" };
261 }
262 if (!isAiAvailable()) {
263 return {
264 attempted: false,
265 success: false,
266 filesChanged: [],
267 summary: "AI not configured",
268 };
269 }
270
271 const repoDir = getRepoPath(owner, repo);
272 const wt = await createWorktree(repoDir, branch);
273 if (!wt.ok) {
274 return {
275 attempted: true,
276 success: false,
277 filesChanged: [],
278 summary: "could not create worktree",
279 error: wt.error,
280 };
281 }
282
283 try {
284 // Group by file, read + patch each
285 const byFile = new Map<string, SecurityFinding[]>();
286 for (const f of findings) {
287 if (!byFile.has(f.file)) byFile.set(f.file, []);
288 byFile.get(f.file)!.push(f);
289 }
290
291 const client = getAnthropic();
292 const patches: Patch[] = [];
293
294 for (const [file, fileFindings] of byFile) {
295 let original: string;
296 try {
297 original = await readFile(join(wt.path, file), "utf8");
298 } catch {
299 continue;
300 }
301 const findingsText = fileFindings
302 .map(
303 (f, i) =>
304 `${i + 1}. [${f.severity}] ${f.type}${f.line ? ` on line ${f.line}` : ""}: ${f.description}${f.suggestion ? `\n Suggestion: ${f.suggestion}` : ""}`
305 )
306 .join("\n");
307
308 const message = await client.messages.create({
309 model: MODEL_SONNET,
310 max_tokens: 8192,
311 messages: [
312 {
313 role: "user",
314 content: `You are a secure-coding assistant. A security scan flagged the following issues in "${file}":
315
316${findingsText}
317
318Rewrite the file to fix ALL flagged issues while preserving existing behaviour. Rules:
319- Output ONLY the full corrected file content. No prose, no code fences.
320- Do not add feature changes unrelated to the findings.
321- Keep imports / exports intact.
322- If an issue genuinely can't be fixed without breaking behaviour, return the file unchanged.
323
324Current file:
325${original}`,
326 },
327 ],
328 });
329 const fixed = extractText(message).replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
330 if (fixed && fixed !== original && fixed.length > 10) {
331 patches.push({
332 path: file,
333 content: fixed,
334 reason: `Fix ${fileFindings.length} security finding${fileFindings.length === 1 ? "" : "s"}`,
335 });
336 }
337 }
338
339 if (patches.length === 0) {
340 return {
341 attempted: true,
342 success: false,
343 filesChanged: [],
344 summary: "AI produced no patches",
345 };
346 }
347
348 const msg = `fix(security): auto-repair flagged issues
349
350${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
351
352[auto-repair by GlueCron AI]`;
353
354 const result = await applyAndCommit(repoDir, wt.path, branch, patches, msg);
355 if (!result.ok) {
356 return {
357 attempted: true,
358 success: false,
359 filesChanged: result.filesChanged,
360 summary: "commit failed",
361 error: result.error,
362 };
363 }
364 return {
365 attempted: true,
366 success: true,
367 commitSha: result.sha,
368 filesChanged: result.filesChanged,
369 summary: `Repaired ${findings.length} security issue${findings.length === 1 ? "" : "s"} in ${patches.length} file${patches.length === 1 ? "" : "s"}`,
370 };
371 } finally {
372 await cleanupWorktree(repoDir, wt.path);
373 }
374}
375
376/**
377 * Given a GateTest failure summary, ask Claude to produce a patch set
378 * that should make the failing check pass.
379 */
380export async function repairGateFailure(
381 owner: string,
382 repo: string,
383 branch: string,
384 gateName: string,
385 failureDetails: string,
386 context: { file: string; content: string }[]
387): Promise<RepairResult> {
388 if (!isAiAvailable()) {
389 return { attempted: false, success: false, filesChanged: [], summary: "AI not configured" };
390 }
391 if (context.length === 0) {
392 return { attempted: false, success: false, filesChanged: [], summary: "no files to analyse" };
393 }
394
395 const repoDir = getRepoPath(owner, repo);
396 const wt = await createWorktree(repoDir, branch);
397 if (!wt.ok) {
398 return { attempted: true, success: false, filesChanged: [], summary: "worktree failed", error: wt.error };
399 }
400
401 try {
402 const client = getAnthropic();
403 const contextBlob = context
404 .map((f) => `FILE: ${f.file}\n---\n${f.content.slice(0, 8000)}\n---\n`)
405 .join("\n");
406
407 const message = await client.messages.create({
408 model: MODEL_SONNET,
409 max_tokens: 8192,
410 messages: [
411 {
412 role: "user",
413 content: `A gate named "${gateName}" failed on repository ${owner}/${repo} (branch ${branch}).
414
415Failure details:
416${failureDetails}
417
418Relevant files:
419${contextBlob}
420
421Produce a minimal JSON patch set that fixes the failure. Respond ONLY with JSON:
422{
423 "patches": [
424 { "path": "relative/path.ts", "content": "FULL new file content", "reason": "..." }
425 ],
426 "summary": "One sentence describing the fix"
427}
428
429If you cannot safely fix the failure, respond with { "patches": [], "summary": "Unable to auto-fix: ..." }.`,
430 },
431 ],
432 });
433 const text = extractText(message);
434 const parsed = parseJsonResponse<{ patches: Patch[]; summary: string }>(text);
435 if (!parsed || !Array.isArray(parsed.patches) || parsed.patches.length === 0) {
436 return {
437 attempted: true,
438 success: false,
439 filesChanged: [],
440 summary: parsed?.summary || "AI produced no patches",
441 };
442 }
443
444 const msg = `fix(${gateName.toLowerCase().replace(/\s+/g, "-")}): auto-repair gate failure
445
446${parsed.summary}
447
448${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
449
450[auto-repair by GlueCron AI]`;
451
452 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
453 if (!result.ok) {
454 return {
455 attempted: true,
456 success: false,
457 filesChanged: result.filesChanged,
458 summary: "commit failed",
459 error: result.error,
460 };
461 }
462 return {
463 attempted: true,
464 success: true,
465 commitSha: result.sha,
466 filesChanged: result.filesChanged,
467 summary: parsed.summary,
468 };
469 } finally {
470 await cleanupWorktree(repoDir, wt.path);
471 }
472}
Addedsrc/lib/codeowners.ts+120−0View fileUnifiedSplit
@@ -0,0 +1,120 @@
1/**
2 * CODEOWNERS parser + sync.
3 *
4 * Parses a CODEOWNERS file (GitHub-compatible syntax):
5 * # comments allowed
6 * * @alice
7 * src/api/** @bob @carol
8 * /docs @alice
9 *
10 * Ownership is resolved by last-matching rule (that's how GitHub does it).
11 */
12
13import { eq } from "drizzle-orm";
14import { db } from "../db";
15import { codeOwners } from "../db/schema";
16
17export interface OwnerRule {
18 pattern: string;
19 owners: string[]; // usernames, stripped of leading @
20}
21
22export function parseCodeowners(content: string): OwnerRule[] {
23 const rules: OwnerRule[] = [];
24 for (const rawLine of content.split("\n")) {
25 const line = rawLine.replace(/#.*$/, "").trim();
26 if (!line) continue;
27 const parts = line.split(/\s+/);
28 if (parts.length < 2) continue;
29 const pattern = parts[0];
30 const owners = parts
31 .slice(1)
32 .map((o) => o.replace(/^@/, "").trim())
33 .filter(Boolean);
34 if (owners.length === 0) continue;
35 rules.push({ pattern, owners });
36 }
37 return rules;
38}
39
40/**
41 * Glob-to-regex for CODEOWNERS patterns. Supports `*` and `**`.
42 * Patterns anchored at the repo root if they start with `/`.
43 */
44function patternToRegex(pattern: string): RegExp {
45 const anchored = pattern.startsWith("/");
46 let p = anchored ? pattern.slice(1) : pattern;
47 // Escape regex metacharacters except * and /
48 p = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
49 p = p.replace(/\*\*/g, "__DOUBLEGLOB__");
50 p = p.replace(/\*/g, "[^/]*");
51 p = p.replace(/__DOUBLEGLOB__/g, ".*");
52 const prefix = anchored ? "^" : "^(?:.*/)?";
53 const suffix = p.endsWith("/") ? ".*$" : "(?:/.*)?$";
54 return new RegExp(prefix + p + suffix);
55}
56
57/**
58 * Return owner usernames for a given file path. Last matching rule wins.
59 */
60export function ownersForPath(
61 path: string,
62 rules: OwnerRule[]
63): string[] {
64 let matched: string[] = [];
65 for (const r of rules) {
66 if (patternToRegex(r.pattern).test(path)) {
67 matched = r.owners;
68 }
69 }
70 return matched;
71}
72
73/**
74 * Replace all rules for a repo in the DB.
75 */
76export async function syncCodeowners(
77 repositoryId: string,
78 rules: OwnerRule[]
79): Promise<void> {
80 try {
81 await db.delete(codeOwners).where(eq(codeOwners.repositoryId, repositoryId));
82 if (rules.length === 0) return;
83 await db.insert(codeOwners).values(
84 rules.map((r) => ({
85 repositoryId,
86 pathPattern: r.pattern,
87 ownerUsernames: r.owners.join(","),
88 }))
89 );
90 } catch (err) {
91 console.error("[codeowners] sync failed:", err);
92 }
93}
94
95/**
96 * Given a PR's changed file list, return all unique owner usernames to
97 * auto-request review from.
98 */
99export async function reviewersForChangedFiles(
100 repositoryId: string,
101 paths: string[]
102): Promise<string[]> {
103 try {
104 const rules = await db
105 .select()
106 .from(codeOwners)
107 .where(eq(codeOwners.repositoryId, repositoryId));
108 const parsed: OwnerRule[] = rules.map((r) => ({
109 pattern: r.pathPattern,
110 owners: r.ownerUsernames.split(",").filter(Boolean),
111 }));
112 const result = new Set<string>();
113 for (const p of paths) {
114 for (const u of ownersForPath(p, parsed)) result.add(u);
115 }
116 return [...result];
117 } catch {
118 return [];
119 }
120}
Modifiedsrc/lib/gate.ts+276−48View fileUnifiedSplit
@@ -1,19 +1,35 @@
11/**
2 * Green gate enforcement.
2 * Green gate enforcement — the heart of the "nothing broken ships" guarantee.
33 *
4 * Checks that all quality gates pass before a merge is allowed:
5 * 1. GateTest scan — runs automated tests/checks via the GateTest API
6 * 2. AI code review — must be approved (no blocking issues)
4 * Runs every configured gate for a repo on push / on PR / on merge:
5 * 1. GateTest scan (external test/lint runner)
6 * 2. Secret scan (regex secrets + AI security review)
7 * 3. AI code review (for PRs)
8 * 4. Merge check (for PRs)
9 * 5. Dependency/vuln scan (best-effort, skipped if not configured)
710 *
8 * Nothing ships unless everything is green.
11 * Each result is persisted to `gate_runs`. If auto-repair is enabled
12 * and a gate fails, the engine attempts a fix before reporting a hard fail.
913 */
1014
15import { eq } from "drizzle-orm";
1116import { config } from "./config";
17import { db } from "../db";
18import { gateRuns, repoSettings, repositories, users } from "../db/schema";
19import { getOrCreateSettings } from "./repo-bootstrap";
20import { scanForSecrets, aiSecurityScan } from "./security-scan";
21import type { SecretFinding, SecurityFinding } from "./security-scan";
22import { repairSecrets, repairSecurityIssues } from "./auto-repair";
23import { readFile } from "fs/promises";
24import { join } from "path";
1225
1326export interface GateCheckResult {
1427 name: string;
1528 passed: boolean;
1629 details: string;
30 skipped?: boolean;
31 repaired?: boolean;
32 repairCommitSha?: string;
1733}
1834
1935export interface GateResult {
@@ -21,9 +37,68 @@ export interface GateResult {
2137 checks: GateCheckResult[];
2238}
2339
40/**
41 * Record a gate run in the DB. Fire-and-forget; swallows DB errors.
42 */
43async function recordGateRun(opts: {
44 repositoryId: string;
45 pullRequestId?: string;
46 commitSha: string;
47 ref: string;
48 gateName: string;
49 status: "passed" | "failed" | "skipped" | "repaired";
50 summary: string;
51 details?: unknown;
52 repairAttempted?: boolean;
53 repairSucceeded?: boolean;
54 repairCommitSha?: string;
55 durationMs?: number;
56}): Promise<void> {
57 try {
58 await db.insert(gateRuns).values({
59 repositoryId: opts.repositoryId,
60 pullRequestId: opts.pullRequestId,
61 commitSha: opts.commitSha,
62 ref: opts.ref,
63 gateName: opts.gateName,
64 status: opts.status,
65 summary: opts.summary,
66 details: opts.details ? JSON.stringify(opts.details) : null,
67 repairAttempted: opts.repairAttempted ?? false,
68 repairSucceeded: opts.repairSucceeded ?? false,
69 repairCommitSha: opts.repairCommitSha,
70 durationMs: opts.durationMs,
71 completedAt: new Date(),
72 });
73 } catch (err) {
74 console.error("[gate] recordGateRun failed:", err);
75 }
76}
77
78/**
79 * Look up the repository row by owner/name.
80 */
81async function lookupRepo(
82 owner: string,
83 repo: string
84): Promise<{ id: string } | null> {
85 try {
86 const [u] = await db.select().from(users).where(eq(users.username, owner)).limit(1);
87 if (!u) return null;
88 const { and } = await import("drizzle-orm");
89 const [r] = await db
90 .select()
91 .from(repositories)
92 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, repo)))
93 .limit(1);
94 return r ? { id: r.id } : null;
95 } catch {
96 return null;
97 }
98}
99
24100/**
25101 * Run GateTest scan on a repository at a specific ref.
26 * Returns pass/fail with details.
27102 */
28103export async function runGateTestScan(
29104 owner: string,
@@ -32,13 +107,11 @@ export async function runGateTestScan(
32107 headSha: string
33108): Promise<GateCheckResult> {
34109 if (!config.gatetestUrl) {
35 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped" };
110 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped", skipped: true };
36111 }
37112
38113 try {
39 const headers: Record<string, string> = {
40 "Content-Type": "application/json",
41 };
114 const headers: Record<string, string> = { "Content-Type": "application/json" };
42115 if (config.gatetestApiKey) {
43116 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
44117 }
@@ -51,8 +124,9 @@ export async function runGateTestScan(
51124 ref,
52125 sha: headSha,
53126 source: "gluecron",
54 mode: "blocking", // Wait for results instead of fire-and-forget
127 mode: "blocking",
55128 }),
129 signal: AbortSignal.timeout(60_000),
56130 });
57131
58132 if (!response.ok) {
@@ -64,17 +138,12 @@ export async function runGateTestScan(
64138 };
65139 }
66140
67 const result = await response.json().catch(() => ({})) as Record<string, unknown>;
68
69 // GateTest API returns { passed: boolean, summary: string, issues: [...] }
70 const passed = result.passed === true || result.status === "passed" || result.status === "success";
71 const summary = (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
72
73 return {
74 name: "GateTest",
75 passed,
76 details: summary,
77 };
141 const result = (await response.json().catch(() => ({}))) as Record<string, unknown>;
142 const passed =
143 result.passed === true || result.status === "passed" || result.status === "success";
144 const summary =
145 (result.summary as string) || (result.message as string) || (passed ? "All checks passed" : "Checks failed");
146 return { name: "GateTest", passed, details: summary };
78147 } catch (err) {
79148 console.error("[gate] GateTest scan error:", err);
80149 return {
@@ -97,25 +166,15 @@ export async function checkMergeability(
97166 const { getRepoPath } = await import("../git/repository");
98167 const repoDir = getRepoPath(owner, repo);
99168
100 const proc = Bun.spawn(
101 ["git", "merge-tree", `$(git merge-base ${baseBranch} ${headBranch})`, baseBranch, headBranch],
102 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
103 );
104 // merge-tree isn't ideal — use merge --no-commit in a worktree style check
105 await proc.exited;
106
107 // Simpler: check if merge-base --is-ancestor works (fast-forward possible)
108169 const ffCheck = Bun.spawn(
109170 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
110171 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
111172 );
112173 const ffExit = await ffCheck.exited;
113
114174 if (ffExit === 0) {
115175 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
116176 }
117177
118 // Check if there would be conflicts
119178 const mergeBase = Bun.spawn(
120179 ["git", "merge-base", baseBranch, headBranch],
121180 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
@@ -127,7 +186,6 @@ export async function checkMergeability(
127186 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
128187 }
129188
130 // Use merge-tree (three-way) to detect conflicts without touching working tree
131189 const mergeTree = Bun.spawn(
132190 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
133191 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
@@ -136,7 +194,6 @@ export async function checkMergeability(
136194 await mergeTree.exited;
137195
138196 const hasConflicts = treeOut.includes("<<<<<<<");
139
140197 return {
141198 name: "Merge check",
142199 passed: !hasConflicts,
@@ -147,7 +204,93 @@ export async function checkMergeability(
147204}
148205
149206/**
150 * Run all gate checks for a PR merge.
207 * Secret + security scan. Runs the regex scanner on files at the given ref,
208 * then optionally runs the AI semantic scan on the diff.
209 */
210export async function runSecretAndSecurityScan(
211 owner: string,
212 repo: string,
213 ref: string,
214 headSha: string,
215 opts: { scanSecrets: boolean; scanSecurity: boolean; diffText?: string }
216): Promise<{
217 secretResult: GateCheckResult;
218 securityResult: GateCheckResult;
219 secrets: SecretFinding[];
220 securityIssues: SecurityFinding[];
221}> {
222 const { getRepoPath, getTree, getBlob, listBranches } = await import("../git/repository");
223 const repoDir = getRepoPath(owner, repo);
224
225 // Snapshot top-level + one level deep files at the ref
226 const files: Array<{ path: string; content: string }> = [];
227 const branches = await listBranches(owner, repo);
228 const effectiveRef = branches.includes(ref.replace(/^refs\/heads\//, ""))
229 ? ref.replace(/^refs\/heads\//, "")
230 : headSha;
231
232 async function walk(dir: string, depth: number): Promise<void> {
233 if (depth > 4) return;
234 const tree = await getTree(owner, repo, effectiveRef, dir);
235 for (const entry of tree) {
236 const full = dir ? `${dir}/${entry.name}` : entry.name;
237 if (entry.type === "tree") {
238 await walk(full, depth + 1);
239 } else if (entry.type === "blob" && (entry.size ?? 0) < 200_000) {
240 try {
241 const blob = await getBlob(owner, repo, effectiveRef, full);
242 if (blob && !blob.isBinary) {
243 files.push({ path: full, content: blob.content });
244 }
245 } catch {
246 // skip
247 }
248 }
249 if (files.length >= 500) return;
250 }
251 }
252
253 try {
254 await walk("", 0);
255 } catch {
256 // Unable to walk — the ref may not exist yet. Bail gracefully.
257 }
258
259 const secrets = opts.scanSecrets ? scanForSecrets(files) : [];
260 const securityIssues =
261 opts.scanSecurity && opts.diffText ? await aiSecurityScan(`${owner}/${repo}`, opts.diffText) : [];
262
263 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
264 const criticalSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
265
266 return {
267 secretResult: {
268 name: "Secret scan",
269 passed: criticalSecrets === 0,
270 details:
271 secrets.length === 0
272 ? "No secrets detected"
273 : `Found ${secrets.length} secret${secrets.length === 1 ? "" : "s"} (${criticalSecrets} critical)`,
274 },
275 securityResult: {
276 name: "Security scan",
277 passed: criticalSec === 0,
278 skipped: !opts.scanSecurity || !opts.diffText,
279 details:
280 securityIssues.length === 0
281 ? opts.scanSecurity && opts.diffText
282 ? "No security issues found"
283 : "Skipped — no diff provided"
284 : `Found ${securityIssues.length} issue${securityIssues.length === 1 ? "" : "s"} (${criticalSec} high/critical)`,
285 },
286 secrets,
287 securityIssues,
288 };
289}
290
291/**
292 * Run every configured gate for a PR merge.
293 * Records gate_runs entries. Optionally invokes auto-repair.
151294 */
152295export async function runAllGateChecks(
153296 owner: string,
@@ -155,30 +298,115 @@ export async function runAllGateChecks(
155298 baseBranch: string,
156299 headBranch: string,
157300 headSha: string,
158 aiReviewApproved: boolean
301 aiReviewApproved: boolean,
302 opts: {
303 pullRequestId?: string;
304 enableAutoRepair?: boolean;
305 diffText?: string;
306 } = {}
159307): Promise<GateResult> {
160 const checks: GateCheckResult[] = [];
308 const started = Date.now();
309 const repoRow = await lookupRepo(owner, repo);
310 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
311
312 // Decide which gates to run
313 const runGateTest = settings?.gateTestEnabled !== false && !!config.gatetestUrl;
314 const runSecretScan = settings?.secretScanEnabled !== false;
315 const runSecurityScan = settings?.securityScanEnabled !== false;
316 const runAiReview = settings?.aiReviewEnabled !== false;
317 const enableRepair = opts.enableAutoRepair !== false && settings?.autoFixEnabled !== false;
161318
162 // Run GateTest and mergeability check in parallel
163 const [gateTestResult, mergeResult] = await Promise.all([
164 runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha),
319 const [gateTestResult, mergeResult, scanResults] = await Promise.all([
320 runGateTest
321 ? runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha)
322 : Promise.resolve<GateCheckResult>({
323 name: "GateTest",
324 passed: true,
325 skipped: true,
326 details: "Disabled in settings",
327 }),
165328 checkMergeability(owner, repo, baseBranch, headBranch),
329 runSecretAndSecurityScan(owner, repo, `refs/heads/${headBranch}`, headSha, {
330 scanSecrets: runSecretScan,
331 scanSecurity: runSecurityScan,
332 diffText: opts.diffText,
333 }),
166334 ]);
167335
168 checks.push(gateTestResult);
336 const checks: GateCheckResult[] = [gateTestResult];
337 checks.push(scanResults.secretResult);
338 if (runSecurityScan) checks.push(scanResults.securityResult);
169339 checks.push(mergeResult);
170
171 // AI review check
172340 checks.push({
173341 name: "AI Review",
174 passed: aiReviewApproved,
175 details: aiReviewApproved
176 ? "AI review approved"
177 : "AI review found blocking issues — resolve before merging",
342 passed: !runAiReview || aiReviewApproved,
343 skipped: !runAiReview,
344 details: !runAiReview
345 ? "Disabled in settings"
346 : aiReviewApproved
347 ? "AI review approved"
348 : "AI review found blocking issues — resolve before merging",
178349 });
179350
351 // ---- Auto-repair on failures ----
352 if (enableRepair) {
353 // Secrets
354 if (!scanResults.secretResult.passed) {
355 const repair = await repairSecrets(owner, repo, headBranch, scanResults.secrets);
356 if (repair.success) {
357 scanResults.secretResult.passed = true;
358 scanResults.secretResult.repaired = true;
359 scanResults.secretResult.repairCommitSha = repair.commitSha;
360 scanResults.secretResult.details = `Auto-redacted ${scanResults.secrets.length} secret${scanResults.secrets.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
361 }
362 }
363 // Security
364 if (!scanResults.securityResult.passed && scanResults.securityIssues.length > 0) {
365 const repair = await repairSecurityIssues(
366 owner,
367 repo,
368 headBranch,
369 scanResults.securityIssues
370 );
371 if (repair.success) {
372 scanResults.securityResult.passed = true;
373 scanResults.securityResult.repaired = true;
374 scanResults.securityResult.repairCommitSha = repair.commitSha;
375 scanResults.securityResult.details = `Auto-repaired ${repair.filesChanged.length} file${repair.filesChanged.length === 1 ? "" : "s"} (${repair.commitSha?.slice(0, 7)})`;
376 }
377 }
378 }
379
380 // Persist gate_runs
381 if (repoRow) {
382 const duration = Date.now() - started;
383 await Promise.all(
384 checks.map((check) =>
385 recordGateRun({
386 repositoryId: repoRow.id,
387 pullRequestId: opts.pullRequestId,
388 commitSha: headSha,
389 ref: `refs/heads/${headBranch}`,
390 gateName: check.name,
391 status: check.skipped
392 ? "skipped"
393 : check.repaired
394 ? "repaired"
395 : check.passed
396 ? "passed"
397 : "failed",
398 summary: check.details,
399 repairAttempted: !!check.repaired,
400 repairSucceeded: !!check.repaired,
401 repairCommitSha: check.repairCommitSha,
402 durationMs: duration,
403 })
404 )
405 );
406 }
407
180408 return {
181 allPassed: checks.every((c) => c.passed),
409 allPassed: checks.every((c) => c.passed || c.skipped),
182410 checks,
183411 };
184412}
Addedsrc/lib/notify.ts+105−0View fileUnifiedSplit
@@ -0,0 +1,105 @@
1/**
2 * Notifications + audit log helpers.
3 * Swallows DB failures so notifications never break the primary request path.
4 */
5
6import { db } from "../db";
7import { notifications, auditLog } from "../db/schema";
8
9export type NotificationKind =
10 | "mention"
11 | "review_requested"
12 | "pr_opened"
13 | "pr_merged"
14 | "pr_closed"
15 | "issue_opened"
16 | "issue_closed"
17 | "assigned"
18 | "ai_review"
19 | "gate_failed"
20 | "gate_repaired"
21 | "gate_passed"
22 | "security_alert"
23 | "deploy_success"
24 | "deploy_failed"
25 | "release_published"
26 | "repo_archived";
27
28export async function notify(
29 userId: string,
30 opts: {
31 kind: NotificationKind;
32 title: string;
33 body?: string;
34 url?: string;
35 repositoryId?: string;
36 }
37): Promise<void> {
38 try {
39 await db.insert(notifications).values({
40 userId,
41 kind: opts.kind,
42 title: opts.title,
43 body: opts.body,
44 url: opts.url,
45 repositoryId: opts.repositoryId,
46 });
47 } catch (err) {
48 console.error("[notify] failed:", err);
49 }
50}
51
52export async function notifyMany(
53 userIds: string[],
54 opts: {
55 kind: NotificationKind;
56 title: string;
57 body?: string;
58 url?: string;
59 repositoryId?: string;
60 }
61): Promise<void> {
62 const unique = Array.from(new Set(userIds));
63 if (unique.length === 0) return;
64 try {
65 await db.insert(notifications).values(
66 unique.map((userId) => ({
67 userId,
68 kind: opts.kind,
69 title: opts.title,
70 body: opts.body,
71 url: opts.url,
72 repositoryId: opts.repositoryId,
73 }))
74 );
75 } catch (err) {
76 console.error("[notify] batch failed:", err);
77 }
78}
79
80export async function audit(opts: {
81 userId?: string | null;
82 repositoryId?: string | null;
83 action: string;
84 targetType?: string;
85 targetId?: string;
86 ip?: string;
87 userAgent?: string;
88 metadata?: Record<string, unknown>;
89}): Promise<void> {
90 try {
91 await db.insert(auditLog).values({
92 userId: opts.userId ?? null,
93 repositoryId: opts.repositoryId ?? null,
94 action: opts.action,
95 targetType: opts.targetType,
96 targetId: opts.targetId,
97 ip: opts.ip,
98 userAgent: opts.userAgent,
99 metadata: opts.metadata ? JSON.stringify(opts.metadata) : null,
100 });
101 } catch (err) {
102 // Audit must never break the primary flow
103 console.error("[audit] failed:", err);
104 }
105}
Addedsrc/lib/repo-bootstrap.ts+210−0View fileUnifiedSplit
@@ -0,0 +1,210 @@
1/**
2 * Repo bootstrap — wires up the "full green ecosystem by default" stance.
3 *
4 * Called immediately after a new repository row is created (including on fork).
5 * Every setting defaults to the most protective configuration — all gates on,
6 * auto-repair on, auto-deploy gated on all-green. Owners can turn things off
7 * in settings but they never have to turn things on.
8 *
9 * This is the heart of the "nothing broken reaches the customer" posture.
10 */
11
12import { db } from "../db";
13import {
14 repoSettings,
15 branchProtection,
16 labels,
17 issues,
18 issueComments,
19} from "../db/schema";
20import { audit } from "./notify";
21
22const DEFAULT_LABELS = [
23 { name: "bug", color: "#f85149", description: "Something is broken" },
24 { name: "feature", color: "#1f6feb", description: "New capability" },
25 { name: "enhancement", color: "#58a6ff", description: "Improvement to existing behaviour" },
26 { name: "security", color: "#d29922", description: "Security-related" },
27 { name: "performance", color: "#a371f7", description: "Performance-related" },
28 { name: "docs", color: "#3fb950", description: "Documentation" },
29 { name: "question", color: "#8b949e", description: "Further info requested" },
30 { name: "good first issue", color: "#7ee787", description: "Suitable for new contributors" },
31 { name: "ai-triaged", color: "#bc8cff", description: "Auto-triaged by GlueCron AI" },
32];
33
34const WELCOME_BODY = `Welcome to your new GlueCron repository.
35
36Every repository ships with the **full green ecosystem** enabled by default — nothing broken ever reaches your customers.
37
38## What's enabled out of the box
39
40- **AI code review** on every pull request
41- **Green gate enforcement** — GateTest + AI review + merge check must all pass before merge
42- **Secret & security scanning** on every push
43- **Automated merge conflict resolution** when conflicts arise
44- **AI auto-repair** — failing gates trigger a fix attempt before a human is pinged
45- **Branch protection** on \`main\` — PR required, all gates green, AI approval required
46- **Auto-deploy** to Crontech on every passing push to \`main\`
47- **AI commit messages, PR summaries, and release changelogs** on demand
48
49You can toggle any of this in **Settings → Gates & Auto-repair**. The safe defaults are on.
50
51## Quick start
52
53Push your first commit:
54
55\`\`\`
56git remote add gluecron https://gluecron.com/YOUR_USERNAME/YOUR_REPO.git
57git push -u gluecron main
58\`\`\`
59
60Ask the assistant anything:
61
62\`\`\`
63Click "Ask AI" in the repo nav or press Cmd+K and type your question.
64\`\`\`
65
66Happy shipping.`;
67
68export interface BootstrapResult {
69 settingsCreated: boolean;
70 protectionCreated: boolean;
71 labelsCreated: number;
72 welcomeIssueNumber?: number;
73}
74
75export async function bootstrapRepository(opts: {
76 repositoryId: string;
77 ownerUserId: string;
78 defaultBranch?: string;
79 skipWelcomeIssue?: boolean;
80}): Promise<BootstrapResult> {
81 const branch = opts.defaultBranch || "main";
82 let settingsCreated = false;
83 let protectionCreated = false;
84 let labelsCreated = 0;
85 let welcomeIssueNumber: number | undefined;
86
87 // 1. Settings — all gates on, all AI features on
88 try {
89 await db.insert(repoSettings).values({
90 repositoryId: opts.repositoryId,
91 });
92 settingsCreated = true;
93 } catch (err) {
94 // Ignore unique-violation if settings already exist (fork case)
95 console.warn("[bootstrap] settings:", (err as Error).message);
96 }
97
98 // 2. Branch protection on the default branch — maximum safety
99 try {
100 await db.insert(branchProtection).values({
101 repositoryId: opts.repositoryId,
102 pattern: branch,
103 requirePullRequest: true,
104 requireGreenGates: true,
105 requireAiApproval: true,
106 requireHumanReview: false,
107 requiredApprovals: 0,
108 allowForcePush: false,
109 allowDeletion: false,
110 dismissStaleReviews: true,
111 });
112 protectionCreated = true;
113 } catch (err) {
114 console.warn("[bootstrap] protection:", (err as Error).message);
115 }
116
117 // 3. Default labels
118 try {
119 const rows = DEFAULT_LABELS.map((l) => ({
120 repositoryId: opts.repositoryId,
121 name: l.name,
122 color: l.color,
123 description: l.description,
124 }));
125 await db.insert(labels).values(rows).onConflictDoNothing?.();
126 labelsCreated = rows.length;
127 } catch (err) {
128 // onConflictDoNothing might not be available on all drizzle adapters; best-effort insert
129 for (const l of DEFAULT_LABELS) {
130 try {
131 await db.insert(labels).values({
132 repositoryId: opts.repositoryId,
133 name: l.name,
134 color: l.color,
135 description: l.description,
136 });
137 labelsCreated++;
138 } catch {
139 // already exists — ignore
140 }
141 }
142 }
143
144 // 4. Welcome issue (skippable for forks)
145 if (!opts.skipWelcomeIssue) {
146 try {
147 const [issue] = await db
148 .insert(issues)
149 .values({
150 repositoryId: opts.repositoryId,
151 authorId: opts.ownerUserId,
152 title: "Welcome to GlueCron",
153 body: WELCOME_BODY,
154 state: "open",
155 })
156 .returning();
157 welcomeIssueNumber = issue?.number;
158 } catch (err) {
159 console.warn("[bootstrap] welcome issue:", (err as Error).message);
160 }
161 }
162
163 await audit({
164 userId: opts.ownerUserId,
165 repositoryId: opts.repositoryId,
166 action: "repo.bootstrap",
167 metadata: {
168 settingsCreated,
169 protectionCreated,
170 labelsCreated,
171 welcomeIssueNumber,
172 },
173 });
174
175 return {
176 settingsCreated,
177 protectionCreated,
178 labelsCreated,
179 welcomeIssueNumber,
180 };
181}
182
183/**
184 * Convenience helper to load settings (creates defaults if missing).
185 */
186export async function getOrCreateSettings(repositoryId: string) {
187 const { eq } = await import("drizzle-orm");
188 const [existing] = await db
189 .select()
190 .from(repoSettings)
191 .where(eq(repoSettings.repositoryId, repositoryId))
192 .limit(1);
193 if (existing) return existing;
194
195 try {
196 const [row] = await db
197 .insert(repoSettings)
198 .values({ repositoryId })
199 .returning();
200 return row;
201 } catch {
202 // Race — someone else inserted, re-select
203 const [row] = await db
204 .select()
205 .from(repoSettings)
206 .where(eq(repoSettings.repositoryId, repositoryId))
207 .limit(1);
208 return row;
209 }
210}
Addedsrc/lib/security-scan.ts+213−0View fileUnifiedSplit
@@ -0,0 +1,213 @@
1/**
2 * Security + secret scanner.
3 * Runs on every push (via post-receive) AND every PR.
4 * Combines fast regex detection for secrets with an AI-powered semantic review
5 * for risky patterns (SSRF, SQL injection, XSS, unsafe deserialisation, etc).
6 */
7
8import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
9
10export interface SecretFinding {
11 type: string;
12 file: string;
13 line: number;
14 snippet: string;
15 severity: "critical" | "high" | "medium" | "low";
16}
17
18export interface SecurityFinding {
19 type: string;
20 file: string;
21 line?: number;
22 description: string;
23 severity: "critical" | "high" | "medium" | "low";
24 suggestion?: string;
25}
26
27export interface ScanResult {
28 secrets: SecretFinding[];
29 securityIssues: SecurityFinding[];
30 summary: string;
31 passed: boolean;
32}
33
34interface SecretPattern {
35 type: string;
36 regex: RegExp;
37 severity: SecretFinding["severity"];
38}
39
40// High-signal secret detectors. Ordered most-specific first.
41export const SECRET_PATTERNS: SecretPattern[] = [
42 { type: "AWS Access Key", regex: /\b(AKIA|ASIA|AIDA|AROA)[0-9A-Z]{16}\b/, severity: "critical" },
43 { type: "AWS Secret Key", regex: /aws(.{0,20})?(secret|access)?(.{0,20})?['\"]([A-Za-z0-9/+=]{40})['\"]/i, severity: "critical" },
44 { type: "GitHub Token", regex: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,251}\b/, severity: "critical" },
45 { type: "Anthropic API Key", regex: /\bsk-ant-(api03|admin01)-[A-Za-z0-9_-]{80,}\b/, severity: "critical" },
46 { type: "OpenAI API Key", regex: /\bsk-(proj-|live-)?[A-Za-z0-9_-]{32,}\b/, severity: "critical" },
47 { type: "Stripe Key", regex: /\b(sk_live_|rk_live_|pk_live_)[A-Za-z0-9]{24,}\b/, severity: "critical" },
48 { type: "Slack Token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, severity: "high" },
49 { type: "Google API Key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: "high" },
50 { type: "SendGrid Key", regex: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, severity: "high" },
51 { type: "Twilio Key", regex: /\bSK[0-9a-fA-F]{32}\b/, severity: "high" },
52 { type: "Generic API Token", regex: /(?:api[_-]?key|apikey|access[_-]?token|secret)["'\s:=]+["']?([A-Za-z0-9_\-]{24,})["']?/i, severity: "medium" },
53 { type: "Private Key (PEM)", regex: /-----BEGIN (RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY-----/, severity: "critical" },
54 { type: "JWT", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/, severity: "medium" },
55 { type: "Postgres URL", regex: /\bpostgres(?:ql)?:\/\/[^:\s]+:[^@\s]+@[^/\s]+\/\S+/, severity: "high" },
56 { type: "Mongo URL", regex: /\bmongodb(?:\+srv)?:\/\/[^:\s]+:[^@\s]+@[^/\s]+\/\S+/, severity: "high" },
57];
58
59// Paths we skip entirely (noise, binaries, generated files)
60const SKIP_PATHS = [
61 /(^|\/)\.git\//,
62 /(^|\/)node_modules\//,
63 /(^|\/)vendor\//,
64 /(^|\/)dist\//,
65 /(^|\/)build\//,
66 /(^|\/)\.next\//,
67 /(^|\/)\.cache\//,
68 /\.(png|jpg|jpeg|gif|webp|ico|svg|pdf|mp4|mov|wasm|woff2?|ttf|eot|map)$/i,
69 /(^|\/)bun\.lock(b)?$/,
70 /(^|\/)package-lock\.json$/,
71 /(^|\/)yarn\.lock$/,
72 /(^|\/)pnpm-lock\.yaml$/,
73];
74
75export function shouldSkipPath(path: string): boolean {
76 return SKIP_PATHS.some((re) => re.test(path));
77}
78
79/**
80 * Fast local regex-based secret scanner. No network, no Claude — safe to run
81 * on every push regardless of whether the AI key is configured.
82 */
83export function scanForSecrets(
84 files: Array<{ path: string; content: string }>
85): SecretFinding[] {
86 const findings: SecretFinding[] = [];
87 for (const file of files) {
88 if (shouldSkipPath(file.path)) continue;
89 const lines = file.content.split("\n");
90 for (let i = 0; i < lines.length; i++) {
91 const line = lines[i];
92 // Skip lines that look like placeholders / tests
93 if (
94 /example|placeholder|fake|dummy|your[-_]?api|xxxxx|testkey|changeme/i.test(
95 line
96 )
97 ) {
98 continue;
99 }
100 for (const pattern of SECRET_PATTERNS) {
101 if (pattern.regex.test(line)) {
102 findings.push({
103 type: pattern.type,
104 file: file.path,
105 line: i + 1,
106 snippet: line.trim().slice(0, 200),
107 severity: pattern.severity,
108 });
109 break; // one finding per line
110 }
111 }
112 }
113 }
114 return findings;
115}
116
117/**
118 * Ask Claude to review a diff or snapshot for security issues.
119 * Returns structured findings; safe to call without AI key (returns empty).
120 */
121export async function aiSecurityScan(
122 repoFullName: string,
123 diffOrSnapshot: string
124): Promise<SecurityFinding[]> {
125 if (!isAiAvailable()) return [];
126 const client = getAnthropic();
127
128 try {
129 const message = await client.messages.create({
130 model: MODEL_SONNET,
131 max_tokens: 2048,
132 messages: [
133 {
134 role: "user",
135 content: `You are a security auditor reviewing code on the repository "${repoFullName}".
136
137Analyse the following code for high-signal security issues:
138- Injection (SQL, command, LDAP, XPath)
139- Cross-site scripting (XSS)
140- Insecure deserialisation
141- SSRF / unvalidated redirects
142- Path traversal
143- Broken authentication / authorisation (e.g. missing access checks)
144- Insecure cryptography (weak hashing for passwords, hard-coded IVs)
145- Race conditions with security impact
146- Insufficient input validation at a trust boundary
147
148Do NOT report low-risk style issues, noisy defensive-coding suggestions, or theoretical risks without a plausible trigger.
149
150Respond ONLY with JSON of shape:
151{
152 "findings": [
153 { "type": "SQL Injection", "file": "src/x.ts", "line": 42, "severity": "high", "description": "...", "suggestion": "..." }
154 ]
155}
156
157If the code is clean, return { "findings": [] }.
158
159\`\`\`
160${diffOrSnapshot.slice(0, 80000)}
161\`\`\``,
162 },
163 ],
164 });
165
166 const text = extractText(message);
167 const parsed = parseJsonResponse<{ findings: SecurityFinding[] }>(text);
168 if (!parsed || !Array.isArray(parsed.findings)) return [];
169 // Normalise severity
170 return parsed.findings.map((f) => ({
171 ...f,
172 severity: (["critical", "high", "medium", "low"].includes(f.severity as string)
173 ? f.severity
174 : "medium") as SecurityFinding["severity"],
175 }));
176 } catch (err) {
177 console.error("[security-scan] AI scan failed:", err);
178 return [];
179 }
180}
181
182/**
183 * Run full security scan: regex secrets + (optional) AI security review.
184 */
185export async function runSecurityScan(
186 repoFullName: string,
187 files: Array<{ path: string; content: string }>,
188 diffText?: string
189): Promise<ScanResult> {
190 const secrets = scanForSecrets(files);
191 const securityIssues = diffText
192 ? await aiSecurityScan(repoFullName, diffText)
193 : [];
194
195 const criticalSecrets = secrets.filter((s) => s.severity === "critical").length;
196 const criticalIssues = securityIssues.filter((i) => i.severity === "critical").length;
197 const highIssues = securityIssues.filter((i) => i.severity === "high").length;
198
199 const passed = criticalSecrets === 0 && criticalIssues === 0 && highIssues === 0;
200
201 const parts: string[] = [];
202 if (secrets.length) parts.push(`${secrets.length} secret${secrets.length === 1 ? "" : "s"}`);
203 if (securityIssues.length)
204 parts.push(
205 `${securityIssues.length} security issue${securityIssues.length === 1 ? "" : "s"}`
206 );
207 const summary =
208 parts.length === 0
209 ? "No issues detected"
210 : `Found ${parts.join(" + ")} (${criticalSecrets + criticalIssues} critical, ${highIssues} high)`;
211
212 return { secrets, securityIssues, summary, passed };
213}
Addedsrc/lib/unread.ts+21−0View fileUnifiedSplit
@@ -0,0 +1,21 @@
1/**
2 * Helper to fetch the unread notification count for a user.
3 * Extracted so any handler can pass it to <Layout> without importing the route file.
4 * Errors are swallowed — the nav must never break because of a counter.
5 */
6
7import { and, eq, isNull, sql } from "drizzle-orm";
8import { db } from "../db";
9import { notifications } from "../db/schema";
10
11export async function getUnreadCount(userId: string): Promise<number> {
12 try {
13 const [row] = await db
14 .select({ c: sql<number>`count(*)::int` })
15 .from(notifications)
16 .where(and(eq(notifications.userId, userId), isNull(notifications.readAt)));
17 return row?.c ?? 0;
18 } catch {
19 return 0;
20 }
21}
Addedsrc/middleware/rate-limit.ts+71−0View fileUnifiedSplit
@@ -0,0 +1,71 @@
1/**
2 * Rate limiter — in-memory fixed-window counter keyed by IP (or token).
3 *
4 * Simple on purpose. For multi-node deployments swap the Map for the
5 * `rate_limit_buckets` Postgres table (schema is already there).
6 */
7
8import { createMiddleware } from "hono/factory";
9
10interface Bucket {
11 count: number;
12 windowStart: number;
13}
14
15const store = new Map<string, Bucket>();
16
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)}`;
21 }
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}
29
30export function rateLimit(opts: { windowMs: number; max: number; prefix?: string }) {
31 const prefix = opts.prefix || "rl";
32 return createMiddleware(async (c, next) => {
33 const key = clientKey(c, prefix);
34 const now = Date.now();
35 const bucket = store.get(key);
36 let count = 1;
37 if (!bucket || now - bucket.windowStart > opts.windowMs) {
38 store.set(key, { count: 1, windowStart: now });
39 } else {
40 bucket.count++;
41 count = bucket.count;
42 if (bucket.count > opts.max) {
43 const retryMs = opts.windowMs - (now - bucket.windowStart);
44 return c.json(
45 { error: "Too many requests", retryAfterMs: retryMs },
46 429,
47 {
48 "Retry-After": String(Math.ceil(retryMs / 1000)),
49 "X-RateLimit-Limit": String(opts.max),
50 "X-RateLimit-Remaining": "0",
51 }
52 );
53 }
54 }
55 // Periodic sweep (every 5 min worth of requests)
56 if (store.size > 10_000) {
57 for (const [k, b] of store) {
58 if (now - b.windowStart > opts.windowMs * 2) store.delete(k);
59 }
60 }
61 await next();
62 // Set rate-limit headers on the final response (persists even if handler errored)
63 if (c.res) {
64 c.res.headers.set("X-RateLimit-Limit", String(opts.max));
65 c.res.headers.set(
66 "X-RateLimit-Remaining",
67 String(Math.max(0, opts.max - count))
68 );
69 }
70 });
71}
Addedsrc/middleware/request-context.ts+31−0View fileUnifiedSplit
@@ -0,0 +1,31 @@
1/**
2 * Request context middleware — attaches a request ID and start time to every
3 * request. Adds the ID to the response headers for correlation + to c.get()
4 * so downstream handlers and loggers can reference it.
5 */
6
7import { createMiddleware } from "hono/factory";
8
9export type RequestContextEnv = {
10 Variables: {
11 requestId: string;
12 requestStart: number;
13 };
14};
15
16function genId(): string {
17 const rand = Math.random().toString(36).slice(2, 10);
18 const ts = Date.now().toString(36);
19 return `${ts}-${rand}`;
20}
21
22export const requestContext = createMiddleware<RequestContextEnv>(
23 async (c, next) => {
24 const existing = c.req.header("x-request-id");
25 const id = existing && existing.length < 100 ? existing : genId();
26 c.set("requestId", id);
27 c.set("requestStart", Date.now());
28 c.header("X-Request-Id", id);
29 await next();
30 }
31);
Modifiedsrc/routes/api.ts+25−6View fileUnifiedSplit
@@ -59,6 +59,15 @@ api.post("/repos", async (c) => {
5959 })
6060 .returning();
6161
62 // Green-ecosystem bootstrap: settings, protection, labels, welcome issue
63 if (repo) {
64 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
65 await bootstrapRepository({
66 repositoryId: repo.id,
67 ownerUserId: owner.id,
68 });
69 }
70
6271 return c.json(repo, 201);
6372});
6473
@@ -128,12 +137,22 @@ api.post("/setup", async (c) => {
128137 // Create repo if not exists
129138 if (!(await repoExists(body.username, body.repoName))) {
130139 const diskPath = await initBareRepo(body.username, body.repoName);
131 await db.insert(repositories).values({
132 name: body.repoName,
133 ownerId: user.id,
134 description: body.description || null,
135 diskPath,
136 });
140 const [repo] = await db
141 .insert(repositories)
142 .values({
143 name: body.repoName,
144 ownerId: user.id,
145 description: body.description || null,
146 diskPath,
147 })
148 .returning();
149 if (repo) {
150 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
151 await bootstrapRepository({
152 repositoryId: repo.id,
153 ownerUserId: user.id,
154 });
155 }
137156 }
138157
139158 return c.json({ user: user.username, repo: body.repoName, status: "ready" });
Addedsrc/routes/ask.tsx+425−0View fileUnifiedSplit
@@ -0,0 +1,425 @@
1/**
2 * AI chat assistant — global + per-repo.
3 *
4 * GET /ask — global assistant (platform Q&A)
5 * GET /ask/:chatId — resume a saved chat
6 * POST /ask — send a message (global)
7 * GET /:owner/:repo/ask — repo-grounded chat
8 * POST /:owner/:repo/ask — send a repo-grounded message
9 * POST /:owner/:repo/explain — "Explain this file" helper
10 *
11 * Chats are persisted to `ai_chats` so users can return to them.
12 * Form-based — works even with JS disabled.
13 */
14
15import { Hono } from "hono";
16import { eq, and, desc } from "drizzle-orm";
17import { db } from "../db";
18import { aiChats, repositories, users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { requireAuth, softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { chat, explainFile } from "../lib/ai-chat";
23import type { ChatMessage } from "../lib/ai-chat";
24import { getUnreadCount } from "../lib/unread";
25import { isAiAvailable } from "../lib/ai-client";
26
27const ask = new Hono<AuthEnv>();
28ask.use("*", softAuth);
29
30function loadMessages(raw: string | null | undefined): ChatMessage[] {
31 if (!raw) return [];
32 try {
33 const parsed = JSON.parse(raw);
34 if (Array.isArray(parsed)) {
35 return parsed.filter(
36 (m): m is ChatMessage =>
37 m && typeof m === "object" && typeof m.content === "string" &&
38 (m.role === "user" || m.role === "assistant")
39 );
40 }
41 } catch {
42 /* ignore */
43 }
44 return [];
45}
46
47function renderChatView(
48 c: any,
49 {
50 messages,
51 postUrl,
52 title,
53 subtitle,
54 placeholder,
55 recentChats,
56 user,
57 unreadCount,
58 }: {
59 messages: ChatMessage[];
60 postUrl: string;
61 title: string;
62 subtitle?: string;
63 placeholder: string;
64 recentChats?: Array<{ id: string; title: string | null; updatedAt: Date }>;
65 user: any;
66 unreadCount: number;
67 }
68) {
69 return c.html(
70 <Layout title={title} user={user} notificationCount={unreadCount}>
71 <div class="ask-container">
72 <div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px">
73 <h2>{title}</h2>
74 {!isAiAvailable() && (
75 <span class="badge" style="color: var(--yellow); border-color: var(--yellow)">
76 AI unavailable — set ANTHROPIC_API_KEY
77 </span>
78 )}
79 </div>
80 {subtitle && (
81 <p style="color: var(--text-muted); margin-bottom: 16px">{subtitle}</p>
82 )}
83
84 <div class="chat-log">
85 {messages.length === 0 ? (
86 <div class="panel-empty">
87 Ask anything. Reference files with @path/to/file.ext.
88 </div>
89 ) : (
90 messages.map((m) => (
91 <div class={`chat-message ${m.role}`}>
92 <div class="role">{m.role === "user" ? "You" : "GlueCron AI"}</div>
93 {m.content}
94 </div>
95 ))
96 )}
97 </div>
98
99 <form method="POST" action={postUrl} class="chat-form">
100 <textarea
101 name="message"
102 placeholder={placeholder}
103 required
104 autofocus
105 ></textarea>
106 <div
107 style="display: flex; justify-content: space-between; align-items: center; margin-top: 8px"
108 >
109 <div class="chat-hint">
110 {"\u21B5"} Enter + Ctrl/Cmd to send. Mention files with
111 <span class="chat-cited" style="margin-left: 4px">@src/file.ts</span>
112 </div>
113 <button type="submit" class="btn btn-primary">
114 Send
115 </button>
116 </div>
117 </form>
118
119 {recentChats && recentChats.length > 0 && (
120 <div style="margin-top: 32px">
121 <h3 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 12px">
122 Recent chats
123 </h3>
124 <div class="panel">
125 {recentChats.map((ch) => (
126 <div class="panel-item">
127 <div class="dot blue"></div>
128 <div style="flex: 1">
129 <a href={`/ask/${ch.id}`}>
130 {ch.title || "(untitled)"}
131 </a>
132 <div class="meta">
133 {new Date(ch.updatedAt).toLocaleString()}
134 </div>
135 </div>
136 </div>
137 ))}
138 </div>
139 </div>
140 )}
141 </div>
142 </Layout>
143 );
144}
145
146// Backwards-compat alias retained so external imports keep working.
147const ChatView = renderChatView;
148
149async function resumeChat(
150 userId: string,
151 chatId: string
152): Promise<{
153 messages: ChatMessage[];
154 repoOwner: string | null;
155 repoName: string | null;
156} | null> {
157 try {
158 const [row] = await db
159 .select({
160 messages: aiChats.messages,
161 userId: aiChats.userId,
162 repositoryId: aiChats.repositoryId,
163 })
164 .from(aiChats)
165 .where(eq(aiChats.id, chatId))
166 .limit(1);
167 if (!row || row.userId !== userId) return null;
168
169 let repoOwner: string | null = null;
170 let repoName: string | null = null;
171 if (row.repositoryId) {
172 const [repo] = await db
173 .select({ name: repositories.name, username: users.username })
174 .from(repositories)
175 .innerJoin(users, eq(repositories.ownerId, users.id))
176 .where(eq(repositories.id, row.repositoryId))
177 .limit(1);
178 if (repo) {
179 repoOwner = repo.username;
180 repoName = repo.name;
181 }
182 }
183
184 return {
185 messages: loadMessages(row.messages),
186 repoOwner,
187 repoName,
188 };
189 } catch {
190 return null;
191 }
192}
193
194async function appendMessage(opts: {
195 userId: string;
196 chatId: string | null;
197 repositoryId: string | null;
198 userMessage: string;
199 aiReply: string;
200 history: ChatMessage[];
201 title: string;
202}): Promise<string> {
203 const newHistory: ChatMessage[] = [
204 ...opts.history,
205 { role: "user", content: opts.userMessage },
206 { role: "assistant", content: opts.aiReply },
207 ];
208 try {
209 if (opts.chatId) {
210 await db
211 .update(aiChats)
212 .set({ messages: JSON.stringify(newHistory), updatedAt: new Date() })
213 .where(eq(aiChats.id, opts.chatId));
214 return opts.chatId;
215 }
216 const [row] = await db
217 .insert(aiChats)
218 .values({
219 userId: opts.userId,
220 repositoryId: opts.repositoryId ?? undefined,
221 title: opts.title.slice(0, 80),
222 messages: JSON.stringify(newHistory),
223 })
224 .returning();
225 return row?.id || "";
226 } catch (err) {
227 console.error("[ask] persist failed:", err);
228 return opts.chatId || "";
229 }
230}
231
232// ---------- Global assistant ----------
233
234ask.get("/ask", requireAuth, async (c) => {
235 const user = c.get("user")!;
236 const unread = await getUnreadCount(user.id);
237 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
238 try {
239 recent = await db
240 .select({
241 id: aiChats.id,
242 title: aiChats.title,
243 updatedAt: aiChats.updatedAt,
244 })
245 .from(aiChats)
246 .where(eq(aiChats.userId, user.id))
247 .orderBy(desc(aiChats.updatedAt))
248 .limit(10);
249 } catch {
250 /* ignore */
251 }
252
253 return renderChatView(c, {
254 messages: [],
255 postUrl: "/ask",
256 title: "Ask AI",
257 subtitle:
258 "Ask about GlueCron, your repos, or paste a diff to review. Claude is grounded in your repo when visiting /:owner/:repo/ask.",
259 placeholder: "Ask anything...",
260 recentChats: recent,
261 user,
262 unreadCount: unread,
263 });
264});
265
266ask.get("/ask/:chatId", requireAuth, async (c) => {
267 const user = c.get("user")!;
268 const chatId = c.req.param("chatId");
269 const resumed = await resumeChat(user.id, chatId);
270 if (!resumed) return c.redirect("/ask");
271 const unread = await getUnreadCount(user.id);
272 return renderChatView(c, {
273 messages: resumed.messages,
274 postUrl:
275 resumed.repoOwner && resumed.repoName
276 ? `/${resumed.repoOwner}/${resumed.repoName}/ask?chatId=${chatId}`
277 : `/ask?chatId=${chatId}`,
278 title:
279 resumed.repoOwner && resumed.repoName
280 ? `${resumed.repoOwner}/${resumed.repoName} — AI chat`
281 : "Ask AI",
282 placeholder: "Continue the conversation...",
283 user,
284 unreadCount: unread,
285 });
286});
287
288ask.post("/ask", requireAuth, async (c) => {
289 const user = c.get("user")!;
290 const body = await c.req.parseBody();
291 const userMessage = String(body.message || "").trim();
292 const chatId = (c.req.query("chatId") || "").trim();
293 if (!userMessage) return c.redirect("/ask");
294
295 let history: ChatMessage[] = [];
296 if (chatId) {
297 const existing = await resumeChat(user.id, chatId);
298 if (existing) history = existing.messages;
299 }
300
301 const response = await chat(user.username, null, history, userMessage);
302 const nextId = await appendMessage({
303 userId: user.id,
304 chatId: chatId || null,
305 repositoryId: null,
306 userMessage,
307 aiReply: response.reply,
308 history,
309 title: userMessage,
310 });
311
312 return c.redirect(nextId ? `/ask/${nextId}` : "/ask");
313});
314
315// ---------- Repo-grounded assistant ----------
316
317ask.get("/:owner/:repo/ask", requireAuth, async (c) => {
318 const user = c.get("user")!;
319 const { owner, repo } = c.req.param();
320
321 // Verify repo exists
322 const [repoRow] = await db
323 .select({ id: repositories.id })
324 .from(repositories)
325 .innerJoin(users, eq(repositories.ownerId, users.id))
326 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
327 .limit(1);
328 if (!repoRow) return c.notFound();
329
330 const unread = await getUnreadCount(user.id);
331
332 let recent: Array<{ id: string; title: string | null; updatedAt: Date }> = [];
333 try {
334 recent = await db
335 .select({
336 id: aiChats.id,
337 title: aiChats.title,
338 updatedAt: aiChats.updatedAt,
339 })
340 .from(aiChats)
341 .where(
342 and(
343 eq(aiChats.userId, user.id),
344 eq(aiChats.repositoryId, repoRow.id)
345 )
346 )
347 .orderBy(desc(aiChats.updatedAt))
348 .limit(10);
349 } catch {
350 /* ignore */
351 }
352
353 return renderChatView(c, {
354 messages: [],
355 postUrl: `/${owner}/${repo}/ask`,
356 title: `Ask about ${owner}/${repo}`,
357 subtitle:
358 "Claude has access to this repository's README, tree, and recent commits. Reference files with @path/to/file.",
359 placeholder: `Ask about ${repo}...`,
360 recentChats: recent,
361 user,
362 unreadCount: unread,
363 });
364});
365
366ask.post("/:owner/:repo/ask", requireAuth, async (c) => {
367 const user = c.get("user")!;
368 const { owner, repo } = c.req.param();
369 const body = await c.req.parseBody();
370 const userMessage = String(body.message || "").trim();
371 const chatId = (c.req.query("chatId") || "").trim();
372 if (!userMessage) return c.redirect(`/${owner}/${repo}/ask`);
373
374 const [repoRow] = await db
375 .select({ id: repositories.id })
376 .from(repositories)
377 .innerJoin(users, eq(repositories.ownerId, users.id))
378 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
379 .limit(1);
380 if (!repoRow) return c.notFound();
381
382 let history: ChatMessage[] = [];
383 if (chatId) {
384 const existing = await resumeChat(user.id, chatId);
385 if (existing) history = existing.messages;
386 }
387
388 const response = await chat(owner, repo, history, userMessage);
389 const nextId = await appendMessage({
390 userId: user.id,
391 chatId: chatId || null,
392 repositoryId: repoRow.id,
393 userMessage,
394 aiReply: response.reply,
395 history,
396 title: userMessage,
397 });
398
399 return c.redirect(
400 nextId ? `/ask/${nextId}` : `/${owner}/${repo}/ask`
401 );
402});
403
404// ---------- Explain-this-file helper ----------
405
406ask.post("/:owner/:repo/explain", requireAuth, async (c) => {
407 const { owner, repo } = c.req.param();
408 const body = await c.req.parseBody().catch(() => ({}));
409 const filePath = String((body as any).file || c.req.query("file") || "");
410 const ref = String((body as any).ref || c.req.query("ref") || "");
411 if (!filePath || !ref) {
412 return c.json({ error: "file and ref required" }, 400);
413 }
414
415 const { getBlob } = await import("../git/repository");
416 const blob = await getBlob(owner, repo, ref, filePath);
417 if (!blob || blob.isBinary) {
418 return c.json({ error: "file not found or binary" }, 404);
419 }
420
421 const explanation = await explainFile(owner, repo, filePath, blob.content);
422 return c.json({ explanation });
423});
424
425export default ask;
Addedsrc/routes/dashboard.tsx+449−0View fileUnifiedSplit
@@ -0,0 +1,449 @@
1/**
2 * Dashboard — the authed user's home page.
3 *
4 * Shows:
5 * - Unread notifications (top 5 with link to full inbox)
6 * - PRs awaiting your review
7 * - PRs you authored that are open
8 * - Issues assigned to you (currently "authored by you" until assignments land)
9 * - Recent activity across your repos
10 * - Your repositories
11 * - Gate health summary across all your repos
12 *
13 * Rendered at `/dashboard`. The `/` route still calls this for logged-in users.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
18import { db } from "../db";
19import {
20 activityFeed,
21 gateRuns,
22 issues,
23 notifications,
24 pullRequests,
25 repositories,
26 users,
27} from "../db/schema";
28import { Layout } from "../views/layout";
29import { RepoCard } from "../views/components";
30import { requireAuth, softAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import { getUnreadCount } from "../lib/unread";
33
34const dashboard = new Hono<AuthEnv>();
35dashboard.use("*", softAuth);
36
37function relTime(d: Date | string): string {
38 const t = typeof d === "string" ? new Date(d) : d;
39 const diffMs = Date.now() - t.getTime();
40 const mins = Math.floor(diffMs / 60000);
41 if (mins < 1) return "just now";
42 if (mins < 60) return `${mins}m ago`;
43 const hrs = Math.floor(mins / 60);
44 if (hrs < 24) return `${hrs}h ago`;
45 const days = Math.floor(hrs / 24);
46 if (days < 30) return `${days}d ago`;
47 return t.toLocaleDateString();
48}
49
50export async function renderDashboard(c: any) {
51 const user = c.get("user")!;
52 const unreadCount = await getUnreadCount(user.id);
53
54 // User's repositories
55 const myRepos = await db
56 .select()
57 .from(repositories)
58 .where(eq(repositories.ownerId, user.id))
59 .orderBy(desc(repositories.updatedAt))
60 .limit(12);
61
62 const myRepoIds = myRepos.map((r) => r.id);
63
64 // Unread notifications (top 5)
65 let recentNotifications: Array<{
66 id: string;
67 kind: string;
68 title: string;
69 url: string | null;
70 createdAt: Date;
71 }> = [];
72 try {
73 recentNotifications = await db
74 .select({
75 id: notifications.id,
76 kind: notifications.kind,
77 title: notifications.title,
78 url: notifications.url,
79 createdAt: notifications.createdAt,
80 })
81 .from(notifications)
82 .where(
83 and(eq(notifications.userId, user.id), isNull(notifications.readAt))
84 )
85 .orderBy(desc(notifications.createdAt))
86 .limit(5);
87 } catch {
88 /* ignore */
89 }
90
91 // Open PRs you authored
92 let myPrs: Array<{
93 id: string;
94 number: number;
95 title: string;
96 state: string;
97 repoName: string;
98 repoOwner: string;
99 createdAt: Date;
100 }> = [];
101 try {
102 myPrs = await db
103 .select({
104 id: pullRequests.id,
105 number: pullRequests.number,
106 title: pullRequests.title,
107 state: pullRequests.state,
108 repoName: repositories.name,
109 repoOwner: users.username,
110 createdAt: pullRequests.createdAt,
111 })
112 .from(pullRequests)
113 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
114 .innerJoin(users, eq(repositories.ownerId, users.id))
115 .where(
116 and(
117 eq(pullRequests.authorId, user.id),
118 eq(pullRequests.state, "open")
119 )
120 )
121 .orderBy(desc(pullRequests.updatedAt))
122 .limit(10);
123 } catch {
124 /* ignore */
125 }
126
127 // PRs in your repos awaiting review (open, not authored by you)
128 let reviewablePrs: Array<{
129 id: string;
130 number: number;
131 title: string;
132 repoName: string;
133 repoOwner: string;
134 createdAt: Date;
135 }> = [];
136 if (myRepoIds.length > 0) {
137 try {
138 reviewablePrs = await db
139 .select({
140 id: pullRequests.id,
141 number: pullRequests.number,
142 title: pullRequests.title,
143 repoName: repositories.name,
144 repoOwner: users.username,
145 createdAt: pullRequests.createdAt,
146 })
147 .from(pullRequests)
148 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
149 .innerJoin(users, eq(repositories.ownerId, users.id))
150 .where(
151 and(
152 inArray(pullRequests.repositoryId, myRepoIds),
153 eq(pullRequests.state, "open")
154 )
155 )
156 .orderBy(desc(pullRequests.updatedAt))
157 .limit(10);
158 } catch {
159 /* ignore */
160 }
161 }
162
163 // Issues you authored that are still open
164 let myIssues: Array<{
165 id: string;
166 number: number;
167 title: string;
168 repoName: string;
169 repoOwner: string;
170 createdAt: Date;
171 }> = [];
172 try {
173 myIssues = await db
174 .select({
175 id: issues.id,
176 number: issues.number,
177 title: issues.title,
178 repoName: repositories.name,
179 repoOwner: users.username,
180 createdAt: issues.createdAt,
181 })
182 .from(issues)
183 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
184 .innerJoin(users, eq(repositories.ownerId, users.id))
185 .where(and(eq(issues.authorId, user.id), eq(issues.state, "open")))
186 .orderBy(desc(issues.updatedAt))
187 .limit(10);
188 } catch {
189 /* ignore */
190 }
191
192 // Recent activity across user's repos
193 let recentActivity: Array<{
194 id: string;
195 action: string;
196 repoName: string;
197 repoOwner: string;
198 createdAt: Date;
199 }> = [];
200 if (myRepoIds.length > 0) {
201 try {
202 recentActivity = await db
203 .select({
204 id: activityFeed.id,
205 action: activityFeed.action,
206 repoName: repositories.name,
207 repoOwner: users.username,
208 createdAt: activityFeed.createdAt,
209 })
210 .from(activityFeed)
211 .innerJoin(repositories, eq(activityFeed.repositoryId, repositories.id))
212 .innerJoin(users, eq(repositories.ownerId, users.id))
213 .where(inArray(activityFeed.repositoryId, myRepoIds))
214 .orderBy(desc(activityFeed.createdAt))
215 .limit(15);
216 } catch {
217 /* ignore */
218 }
219 }
220
221 // Gate health across your repos (last 20 runs)
222 let gateHealth: Array<{
223 gateName: string;
224 status: string;
225 repoName: string;
226 repoOwner: string;
227 createdAt: Date;
228 summary: string | null;
229 }> = [];
230 if (myRepoIds.length > 0) {
231 try {
232 gateHealth = await db
233 .select({
234 gateName: gateRuns.gateName,
235 status: gateRuns.status,
236 repoName: repositories.name,
237 repoOwner: users.username,
238 createdAt: gateRuns.createdAt,
239 summary: gateRuns.summary,
240 })
241 .from(gateRuns)
242 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
243 .innerJoin(users, eq(repositories.ownerId, users.id))
244 .where(inArray(gateRuns.repositoryId, myRepoIds))
245 .orderBy(desc(gateRuns.createdAt))
246 .limit(10);
247 } catch {
248 /* ignore */
249 }
250 }
251
252 const greenCount = gateHealth.filter(
253 (g) => g.status === "passed" || g.status === "repaired" || g.status === "skipped"
254 ).length;
255 const redCount = gateHealth.filter((g) => g.status === "failed").length;
256
257 return c.html(
258 <Layout title="Dashboard" user={user} notificationCount={unreadCount}>
259 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
260 <h2>Welcome back, {user.displayName || user.username}</h2>
261 <a href="/new" class="btn btn-primary">
262 + New repository
263 </a>
264 </div>
265
266 <div class="dashboard-grid">
267 {/* Left column */}
268 <div>
269 <div class="dashboard-section">
270 <h3>
271 Needs your attention
272 <a href="/notifications">view all</a>
273 </h3>
274 <div class="panel">
275 {recentNotifications.length === 0 ? (
276 <div class="panel-empty">Inbox zero. Nice work.</div>
277 ) : (
278 recentNotifications.map((n) => (
279 <div class="panel-item">
280 <div class="dot blue"></div>
281 <div style="flex: 1">
282 {n.url ? (
283 <a href={n.url}>{n.title}</a>
284 ) : (
285 <span>{n.title}</span>
286 )}
287 <div class="meta">
288 {n.kind} · {relTime(n.createdAt)}
289 </div>
290 </div>
291 </div>
292 ))
293 )}
294 </div>
295 </div>
296
297 <div class="dashboard-section">
298 <h3>PRs awaiting review in your repos</h3>
299 <div class="panel">
300 {reviewablePrs.length === 0 ? (
301 <div class="panel-empty">No open PRs in your repositories.</div>
302 ) : (
303 reviewablePrs.map((pr) => (
304 <div class="panel-item">
305 <div class="dot green"></div>
306 <div style="flex: 1">
307 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
308 {pr.title}
309 </a>
310 <div class="meta">
311 {pr.repoOwner}/{pr.repoName}#{pr.number} · opened{" "}
312 {relTime(pr.createdAt)}
313 </div>
314 </div>
315 </div>
316 ))
317 )}
318 </div>
319 </div>
320
321 <div class="dashboard-section">
322 <h3>Your open PRs</h3>
323 <div class="panel">
324 {myPrs.length === 0 ? (
325 <div class="panel-empty">You have no open PRs.</div>
326 ) : (
327 myPrs.map((pr) => (
328 <div class="panel-item">
329 <div class="dot blue"></div>
330 <div style="flex: 1">
331 <a href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}>
332 {pr.title}
333 </a>
334 <div class="meta">
335 {pr.repoOwner}/{pr.repoName}#{pr.number} ·{" "}
336 {relTime(pr.createdAt)}
337 </div>
338 </div>
339 </div>
340 ))
341 )}
342 </div>
343 </div>
344
345 <div class="dashboard-section">
346 <h3>Your open issues</h3>
347 <div class="panel">
348 {myIssues.length === 0 ? (
349 <div class="panel-empty">No open issues.</div>
350 ) : (
351 myIssues.map((i) => (
352 <div class="panel-item">
353 <div class="dot yellow"></div>
354 <div style="flex: 1">
355 <a href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}>
356 {i.title}
357 </a>
358 <div class="meta">
359 {i.repoOwner}/{i.repoName}#{i.number} ·{" "}
360 {relTime(i.createdAt)}
361 </div>
362 </div>
363 </div>
364 ))
365 )}
366 </div>
367 </div>
368 </div>
369
370 {/* Right column */}
371 <div>
372 <div class="dashboard-section">
373 <h3>Gate health</h3>
374 <div class="panel" style="padding: 16px">
375 <div style="display: flex; gap: 12px; margin-bottom: 12px">
376 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius)">
377 <div style="font-size: 24px; font-weight: 700; color: var(--green)">
378 {greenCount}
379 </div>
380 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
381 green
382 </div>
383 </div>
384 <div style="flex: 1; text-align: center; padding: 8px; background: rgba(248, 81, 73, 0.1); border-radius: var(--radius)">
385 <div style="font-size: 24px; font-weight: 700; color: var(--red)">
386 {redCount}
387 </div>
388 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">
389 failed
390 </div>
391 </div>
392 </div>
393 <div style="font-size: 12px; color: var(--text-muted); text-align: center">
394 Last 10 gate runs across your repos
395 </div>
396 </div>
397 </div>
398
399 <div class="dashboard-section">
400 <h3>Recent activity</h3>
401 <div class="panel">
402 {recentActivity.length === 0 ? (
403 <div class="panel-empty">No activity yet.</div>
404 ) : (
405 recentActivity.map((a) => (
406 <div class="panel-item">
407 <div
408 class={`dot ${a.action === "push" ? "green" : a.action === "pr_merge" ? "blue" : "yellow"}`}
409 ></div>
410 <div style="flex: 1">
411 <a href={`/${a.repoOwner}/${a.repoName}`}>
412 {a.repoOwner}/{a.repoName}
413 </a>{" "}
414 <span style="color: var(--text-muted)">{a.action}</span>
415 <div class="meta">{relTime(a.createdAt)}</div>
416 </div>
417 </div>
418 ))
419 )}
420 </div>
421 </div>
422 </div>
423 </div>
424
425 <div class="dashboard-section" style="margin-top: 32px">
426 <h3>
427 Your repositories
428 <a href={`/${user.username}`}>view all</a>
429 </h3>
430 {myRepos.length === 0 ? (
431 <div class="empty-state">
432 <h2>No repositories yet</h2>
433 <p>Create your first repository to get started.</p>
434 </div>
435 ) : (
436 <div class="card-grid">
437 {myRepos.map((repo) => (
438 <RepoCard repo={repo} ownerName={user.username} />
439 ))}
440 </div>
441 )}
442 </div>
443 </Layout>
444 );
445}
446
447dashboard.get("/dashboard", requireAuth, (c) => renderDashboard(c));
448
449export default dashboard;
Modifiedsrc/routes/fork.ts+25−11View fileUnifiedSplit
@@ -67,17 +67,31 @@ fork.post("/:owner/:repo/fork", requireAuth, async (c) => {
6767 await proc.exited;
6868
6969 // Insert into DB
70 await db.insert(repositories).values({
71 name: repoName,
72 ownerId: user.id,
73 description: sourceRepo.description
74 ? `Fork of ${ownerName}/${repoName} — ${sourceRepo.description}`
75 : `Fork of ${ownerName}/${repoName}`,
76 isPrivate: false,
77 defaultBranch: sourceRepo.defaultBranch,
78 diskPath: destPath,
79 forkedFromId: sourceRepo.id,
80 });
70 const [newRepo] = await db
71 .insert(repositories)
72 .values({
73 name: repoName,
74 ownerId: user.id,
75 description: sourceRepo.description
76 ? `Fork of ${ownerName}/${repoName} — ${sourceRepo.description}`
77 : `Fork of ${ownerName}/${repoName}`,
78 isPrivate: false,
79 defaultBranch: sourceRepo.defaultBranch,
80 diskPath: destPath,
81 forkedFromId: sourceRepo.id,
82 })
83 .returning();
84
85 // Bootstrap the fork with green-by-default settings, protection, labels
86 if (newRepo) {
87 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
88 await bootstrapRepository({
89 repositoryId: newRepo.id,
90 ownerUserId: user.id,
91 defaultBranch: sourceRepo.defaultBranch,
92 skipWelcomeIssue: true, // forks don't need a welcome issue
93 });
94 }
8195
8296 // Update fork count
8397 await db
Addedsrc/routes/gates.tsx+497−0View fileUnifiedSplit
@@ -0,0 +1,497 @@
1/**
2 * Gates UI — gate run history + branch protection settings + repo settings toggles.
3 *
4 * GET /:owner/:repo/gates — per-repo gate run history
5 * GET /:owner/:repo/gates/settings — settings toggles + branch protection (owner-only)
6 * POST /:owner/:repo/gates/settings — save toggles
7 * POST /:owner/:repo/gates/protection — save/update branch protection rule
8 * POST /:owner/:repo/gates/protection/:id/delete — remove a protection rule
9 * POST /:owner/:repo/gates/run — manually trigger a gate run on the default branch
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq } from "drizzle-orm";
14import { db } from "../db";
15import {
16 branchProtection,
17 gateRuns,
18 repoSettings,
19 repositories,
20 users,
21} from "../db/schema";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getUnreadCount } from "../lib/unread";
28import { audit } from "../lib/notify";
29
30const gates = new Hono<AuthEnv>();
31gates.use("*", softAuth);
32
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
49
50function relTime(d: Date | string): string {
51 const t = typeof d === "string" ? new Date(d) : d;
52 const diffMs = Date.now() - t.getTime();
53 const mins = Math.floor(diffMs / 60000);
54 if (mins < 1) return "just now";
55 if (mins < 60) return `${mins}m ago`;
56 const hrs = Math.floor(mins / 60);
57 if (hrs < 24) return `${hrs}h ago`;
58 const days = Math.floor(hrs / 24);
59 if (days < 30) return `${days}d ago`;
60 return t.toLocaleDateString();
61}
62
63// ---------- Gate run history ----------
64
65gates.get("/:owner/:repo/gates", async (c) => {
66 const user = c.get("user");
67 const { owner, repo } = c.req.param();
68 const repoRow = await loadRepo(owner, repo);
69 if (!repoRow) return c.notFound();
70
71 const runs = await db
72 .select()
73 .from(gateRuns)
74 .where(eq(gateRuns.repositoryId, repoRow.id))
75 .orderBy(desc(gateRuns.createdAt))
76 .limit(100);
77
78 const unread = user ? await getUnreadCount(user.id) : 0;
79 const total = runs.length;
80 const passed = runs.filter((r) => r.status === "passed").length;
81 const failed = runs.filter((r) => r.status === "failed").length;
82 const repaired = runs.filter((r) => r.status === "repaired").length;
83 const skipped = runs.filter((r) => r.status === "skipped").length;
84
85 return c.html(
86 <Layout
87 title={`Gates — ${owner}/${repo}`}
88 user={user}
89 notificationCount={unread}
90 >
91 <RepoHeader
92 owner={owner}
93 repo={repo}
94 starCount={repoRow.starCount}
95 forkCount={repoRow.forkCount}
96 currentUser={user?.username || null}
97 />
98 <RepoNav owner={owner} repo={repo} active="gates" />
99 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
100 <h3>Gate runs</h3>
101 {user && user.id === repoRow.ownerId && (
102 <a
103 href={`/${owner}/${repo}/gates/settings`}
104 class="btn btn-sm"
105 >
106 {"\u2699"} Settings
107 </a>
108 )}
109 </div>
110
111 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px">
112 <div class="panel" style="padding: 12px; text-align: center">
113 <div style="font-size: 22px; font-weight: 700; color: var(--green)">{passed}</div>
114 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Passed</div>
115 </div>
116 <div class="panel" style="padding: 12px; text-align: center">
117 <div style="font-size: 22px; font-weight: 700; color: #bc8cff">{repaired}</div>
118 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Repaired</div>
119 </div>
120 <div class="panel" style="padding: 12px; text-align: center">
121 <div style="font-size: 22px; font-weight: 700; color: var(--red)">{failed}</div>
122 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Failed</div>
123 </div>
124 <div class="panel" style="padding: 12px; text-align: center">
125 <div style="font-size: 22px; font-weight: 700; color: var(--text-muted)">{skipped}</div>
126 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase">Skipped</div>
127 </div>
128 </div>
129
130 {total === 0 ? (
131 <div class="empty-state">
132 <p>No gate runs yet. Push a commit to trigger the full green ecosystem.</p>
133 </div>
134 ) : (
135 <div class="gate-list">
136 {runs.map((r) => (
137 <div class="gate-run-row">
138 <span class={`gate-status ${r.status}`}>{r.status}</span>
139 <div style="flex: 1">
140 <div style="font-weight: 500">{r.gateName}</div>
141 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
142 <a href={`/${owner}/${repo}/commit/${r.commitSha}`}>
143 {r.commitSha.slice(0, 7)}
144 </a>
145 {" · "}
146 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
147 {" · "}
148 <span>{relTime(r.createdAt)}</span>
149 {r.durationMs ? ` · ${(r.durationMs / 1000).toFixed(1)}s` : ""}
150 </div>
151 {r.summary && (
152 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
153 {r.summary}
154 </div>
155 )}
156 {r.repairCommitSha && (
157 <div style="font-size: 12px; color: #bc8cff; margin-top: 2px">
158 Auto-repaired in{" "}
159 <a href={`/${owner}/${repo}/commit/${r.repairCommitSha}`}>
160 {r.repairCommitSha.slice(0, 7)}
161 </a>
162 </div>
163 )}
164 </div>
165 </div>
166 ))}
167 </div>
168 )}
169 </Layout>
170 );
171});
172
173// ---------- Settings UI ----------
174
175gates.get("/:owner/:repo/gates/settings", requireAuth, async (c) => {
176 const user = c.get("user")!;
177 const { owner, repo } = c.req.param();
178 const repoRow = await loadRepo(owner, repo);
179 if (!repoRow) return c.notFound();
180 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
181
182 const settings = await getOrCreateSettings(repoRow.id);
183 const protections = await db
184 .select()
185 .from(branchProtection)
186 .where(eq(branchProtection.repositoryId, repoRow.id));
187
188 const unread = await getUnreadCount(user.id);
189 const success = c.req.query("success");
190
191 const toggle = (name: string, label: string, checked: boolean, desc?: string) => (
192 <label
193 style="display: flex; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer"
194 >
195 <input type="checkbox" name={name} value="1" checked={checked} />
196 <div>
197 <div style="font-weight: 500">{label}</div>
198 {desc && (
199 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
200 {desc}
201 </div>
202 )}
203 </div>
204 </label>
205 );
206
207 return c.html(
208 <Layout
209 title={`Gate settings — ${owner}/${repo}`}
210 user={user}
211 notificationCount={unread}
212 >
213 <RepoHeader
214 owner={owner}
215 repo={repo}
216 starCount={repoRow.starCount}
217 forkCount={repoRow.forkCount}
218 currentUser={user.username}
219 />
220 <RepoNav owner={owner} repo={repo} active="gates" />
221 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
222 <h3>Gate & auto-repair settings</h3>
223 <a href={`/${owner}/${repo}/gates`} class="btn btn-sm">
224 Back to runs
225 </a>
226 </div>
227 {success && (
228 <div class="auth-success">{decodeURIComponent(success)}</div>
229 )}
230
231 <form method="POST" action={`/${owner}/${repo}/gates/settings`}>
232 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
233 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
234 Gates
235 </div>
236 {toggle("gateTestEnabled", "GateTest scan", settings!.gateTestEnabled, "External test/lint runner")}
237 {toggle("aiReviewEnabled", "AI code review", settings!.aiReviewEnabled, "Claude reviews every PR")}
238 {toggle("secretScanEnabled", "Secret scan", settings!.secretScanEnabled, "Regex + AI secret detection on every push")}
239 {toggle("securityScanEnabled", "Security scan", settings!.securityScanEnabled, "Claude-powered semantic security review")}
240 {toggle("dependencyScanEnabled", "Dependency scan", settings!.dependencyScanEnabled, "Vulnerability scanning on lockfiles")}
241 {toggle("lintEnabled", "Lint", settings!.lintEnabled, "Auto-lint every push")}
242 {toggle("typeCheckEnabled", "Type check", settings!.typeCheckEnabled)}
243 {toggle("testEnabled", "Tests", settings!.testEnabled, "Run your test suite on every push")}
244 </div>
245
246 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
247 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
248 Auto-repair
249 </div>
250 {toggle("autoFixEnabled", "Auto-fix failing gates", settings!.autoFixEnabled, "Claude attempts a fix before a human is pinged")}
251 {toggle("autoMergeResolveEnabled", "Auto-resolve merge conflicts", settings!.autoMergeResolveEnabled)}
252 {toggle("autoFormatEnabled", "Auto-format on commit", settings!.autoFormatEnabled)}
253 </div>
254
255 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
256 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
257 AI features
258 </div>
259 {toggle("aiCommitMessagesEnabled", "AI commit messages", settings!.aiCommitMessagesEnabled)}
260 {toggle("aiPrSummaryEnabled", "AI PR summaries", settings!.aiPrSummaryEnabled)}
261 {toggle("aiChangelogEnabled", "AI release changelogs", settings!.aiChangelogEnabled)}
262 </div>
263
264 <div class="panel" style="margin-bottom: 20px; overflow: hidden">
265 <div style="padding: 12px 14px; background: var(--bg-tertiary); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
266 Deploy
267 </div>
268 {toggle("autoDeployEnabled", "Auto-deploy on green pushes to default branch", settings!.autoDeployEnabled)}
269 {toggle("deployRequireAllGreen", "Block deploys unless all gates are green", settings!.deployRequireAllGreen)}
270 </div>
271
272 <button type="submit" class="btn btn-primary">
273 Save settings
274 </button>
275 </form>
276
277 <h3 style="margin-top: 32px; margin-bottom: 12px">Branch protection</h3>
278 <p style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
279 The default branch is protected on every new repo. Add extra rules for release branches.
280 </p>
281 <div class="panel" style="margin-bottom: 16px">
282 {protections.length === 0 ? (
283 <div class="panel-empty">No protection rules yet.</div>
284 ) : (
285 protections.map((p) => (
286 <div class="panel-item" style="justify-content: space-between">
287 <div style="flex: 1">
288 <code
289 style="background: var(--bg-tertiary); padding: 2px 8px; border-radius: 3px"
290 >
291 {p.pattern}
292 </code>
293 <div class="meta" style="margin-top: 4px">
294 {p.requirePullRequest ? "PR required · " : ""}
295 {p.requireGreenGates ? "Green gates · " : ""}
296 {p.requireAiApproval ? "AI approval · " : ""}
297 {p.requireHumanReview
298 ? `${p.requiredApprovals} human approval(s) · `
299 : ""}
300 {!p.allowForcePush ? "No force push · " : ""}
301 {!p.allowDeletion ? "No deletion" : ""}
302 </div>
303 </div>
304 <form
305 method="POST"
306 action={`/${owner}/${repo}/gates/protection/${p.id}/delete`}
307 onsubmit="return confirm('Remove this rule?')"
308 >
309 <button type="submit" class="btn btn-sm btn-danger">
310 Remove
311 </button>
312 </form>
313 </div>
314 ))
315 )}
316 </div>
317
318 <form
319 method="POST"
320 action={`/${owner}/${repo}/gates/protection`}
321 class="panel"
322 style="padding: 16px"
323 >
324 <div class="form-group">
325 <label>Pattern</label>
326 <input
327 type="text"
328 name="pattern"
329 required
330 placeholder="release/* or main"
331 />
332 </div>
333 <div style="display: flex; flex-wrap: wrap; gap: 16px">
334 <label style="display: flex; align-items: center; gap: 6px">
335 <input type="checkbox" name="requirePullRequest" value="1" checked />
336 Require PR
337 </label>
338 <label style="display: flex; align-items: center; gap: 6px">
339 <input type="checkbox" name="requireGreenGates" value="1" checked />
340 Require green gates
341 </label>
342 <label style="display: flex; align-items: center; gap: 6px">
343 <input type="checkbox" name="requireAiApproval" value="1" checked />
344 Require AI approval
345 </label>
346 <label style="display: flex; align-items: center; gap: 6px">
347 <input type="checkbox" name="requireHumanReview" value="1" />
348 Require human review
349 </label>
350 <label style="display: flex; align-items: center; gap: 6px">
351 Approvals{" "}
352 <input
353 type="number"
354 name="requiredApprovals"
355 min="0"
356 max="10"
357 value="1"
358 style="width: 60px"
359 />
360 </label>
361 <label style="display: flex; align-items: center; gap: 6px">
362 <input type="checkbox" name="allowForcePush" value="1" />
363 Allow force push
364 </label>
365 <label style="display: flex; align-items: center; gap: 6px">
366 <input type="checkbox" name="allowDeletion" value="1" />
367 Allow deletion
368 </label>
369 </div>
370 <button type="submit" class="btn btn-primary" style="margin-top: 12px">
371 Add rule
372 </button>
373 </form>
374 </Layout>
375 );
376});
377
378gates.post("/:owner/:repo/gates/settings", requireAuth, async (c) => {
379 const user = c.get("user")!;
380 const { owner, repo } = c.req.param();
381 const repoRow = await loadRepo(owner, repo);
382 if (!repoRow) return c.notFound();
383 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
384
385 const body = await c.req.parseBody();
386 const b = (k: string) => body[k] === "1" || body[k] === "on";
387
388 try {
389 await db
390 .update(repoSettings)
391 .set({
392 gateTestEnabled: b("gateTestEnabled"),
393 aiReviewEnabled: b("aiReviewEnabled"),
394 secretScanEnabled: b("secretScanEnabled"),
395 securityScanEnabled: b("securityScanEnabled"),
396 dependencyScanEnabled: b("dependencyScanEnabled"),
397 lintEnabled: b("lintEnabled"),
398 typeCheckEnabled: b("typeCheckEnabled"),
399 testEnabled: b("testEnabled"),
400 autoFixEnabled: b("autoFixEnabled"),
401 autoMergeResolveEnabled: b("autoMergeResolveEnabled"),
402 autoFormatEnabled: b("autoFormatEnabled"),
403 aiCommitMessagesEnabled: b("aiCommitMessagesEnabled"),
404 aiPrSummaryEnabled: b("aiPrSummaryEnabled"),
405 aiChangelogEnabled: b("aiChangelogEnabled"),
406 autoDeployEnabled: b("autoDeployEnabled"),
407 deployRequireAllGreen: b("deployRequireAllGreen"),
408 updatedAt: new Date(),
409 })
410 .where(eq(repoSettings.repositoryId, repoRow.id));
411 } catch (err) {
412 console.error("[gates] settings save:", err);
413 }
414
415 await audit({
416 userId: user.id,
417 repositoryId: repoRow.id,
418 action: "gates.settings.update",
419 });
420
421 return c.redirect(
422 `/${owner}/${repo}/gates/settings?success=Settings+saved`
423 );
424});
425
426gates.post("/:owner/:repo/gates/protection", requireAuth, async (c) => {
427 const user = c.get("user")!;
428 const { owner, repo } = c.req.param();
429 const repoRow = await loadRepo(owner, repo);
430 if (!repoRow) return c.notFound();
431 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
432
433 const body = await c.req.parseBody();
434 const pattern = String(body.pattern || "").trim();
435 if (!pattern) return c.redirect(`/${owner}/${repo}/gates/settings`);
436 const b = (k: string) => body[k] === "1" || body[k] === "on";
437 const requiredApprovals = Math.max(
438 0,
439 Math.min(10, parseInt(String(body.requiredApprovals || "0"), 10) || 0)
440 );
441
442 try {
443 await db.insert(branchProtection).values({
444 repositoryId: repoRow.id,
445 pattern,
446 requirePullRequest: b("requirePullRequest"),
447 requireGreenGates: b("requireGreenGates"),
448 requireAiApproval: b("requireAiApproval"),
449 requireHumanReview: b("requireHumanReview"),
450 requiredApprovals,
451 allowForcePush: b("allowForcePush"),
452 allowDeletion: b("allowDeletion"),
453 });
454 } catch (err) {
455 console.error("[gates] protection save:", err);
456 }
457
458 await audit({
459 userId: user.id,
460 repositoryId: repoRow.id,
461 action: "branch_protection.create",
462 metadata: { pattern },
463 });
464
465 return c.redirect(
466 `/${owner}/${repo}/gates/settings?success=Rule+added`
467 );
468});
469
470gates.post(
471 "/:owner/:repo/gates/protection/:id/delete",
472 requireAuth,
473 async (c) => {
474 const user = c.get("user")!;
475 const { owner, repo, id } = c.req.param();
476 const repoRow = await loadRepo(owner, repo);
477 if (!repoRow) return c.notFound();
478 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/gates`);
479 await db
480 .delete(branchProtection)
481 .where(
482 and(
483 eq(branchProtection.id, id),
484 eq(branchProtection.repositoryId, repoRow.id)
485 )
486 );
487 await audit({
488 userId: user.id,
489 repositoryId: repoRow.id,
490 action: "branch_protection.delete",
491 targetId: id,
492 });
493 return c.redirect(`/${owner}/${repo}/gates/settings?success=Rule+removed`);
494 }
495);
496
497export default gates;
Addedsrc/routes/health.ts+60−0View fileUnifiedSplit
@@ -0,0 +1,60 @@
1/**
2 * Health + metrics endpoints for load-balancer + observability.
3 *
4 * GET /healthz — liveness (always 200 if process alive)
5 * GET /readyz — readiness (checks DB is reachable)
6 * GET /metrics — basic in-process counters
7 */
8
9import { Hono } from "hono";
10import { sql } from "drizzle-orm";
11import { db } from "../db";
12
13const health = new Hono();
14
15const started = Date.now();
16const counters = {
17 requests: 0,
18 errors: 0,
19};
20
21// Count every request that reaches any health route
22health.use("*", async (c, next) => {
23 counters.requests++;
24 await next();
25});
26
27health.get("/healthz", (c) => {
28 return c.json({
29 ok: true,
30 uptimeMs: Date.now() - started,
31 });
32});
33
34health.get("/readyz", async (c) => {
35 try {
36 await db.execute(sql`SELECT 1`);
37 return c.json({ ok: true, db: "up" });
38 } catch (err) {
39 counters.errors++;
40 return c.json(
41 { ok: false, db: "down", error: (err as Error).message },
42 503
43 );
44 }
45});
46
47health.get("/metrics", (c) => {
48 const mem = process.memoryUsage();
49 return c.json({
50 uptimeMs: Date.now() - started,
51 requests: counters.requests,
52 errors: counters.errors,
53 heapUsed: mem.heapUsed,
54 heapTotal: mem.heapTotal,
55 rss: mem.rss,
56 nodeVersion: process.version,
57 });
58});
59
60export default health;
Addedsrc/routes/insights.tsx+457−0View fileUnifiedSplit
@@ -0,0 +1,457 @@
1/**
2 * Repo insights + milestones.
3 *
4 * GET /:owner/:repo/insights — contributors, commit activity, gate health, AI-generated summary
5 * GET /:owner/:repo/milestones — list
6 * POST /:owner/:repo/milestones — create
7 * POST /:owner/:repo/milestones/:id/close — close
8 * POST /:owner/:repo/milestones/:id/reopen — reopen
9 * POST /:owner/:repo/milestones/:id/delete — delete
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq, sql } from "drizzle-orm";
14import { db } from "../db";
15import {
16 gateRuns,
17 issues,
18 milestones,
19 pullRequests,
20 repositories,
21 users,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { getUnreadCount } from "../lib/unread";
28import { listCommits } from "../git/repository";
29
30const insights = new Hono<AuthEnv>();
31insights.use("*", softAuth);
32
33async function loadRepo(owner: string, repo: string) {
34 const [row] = await db
35 .select({
36 id: repositories.id,
37 name: repositories.name,
38 defaultBranch: repositories.defaultBranch,
39 ownerId: repositories.ownerId,
40 starCount: repositories.starCount,
41 forkCount: repositories.forkCount,
42 })
43 .from(repositories)
44 .innerJoin(users, eq(repositories.ownerId, users.id))
45 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
46 .limit(1);
47 return row;
48}
49
50// ---------- Insights ----------
51
52insights.get("/:owner/:repo/insights", async (c) => {
53 const user = c.get("user");
54 const { owner, repo } = c.req.param();
55 const repoRow = await loadRepo(owner, repo);
56 if (!repoRow) return c.notFound();
57
58 const unread = user ? await getUnreadCount(user.id) : 0;
59
60 // Commit activity — last 200 commits on default
61 const commits = await listCommits(
62 owner,
63 repo,
64 repoRow.defaultBranch,
65 200
66 );
67
68 // Contributors by commit count
69 const byAuthor = new Map<string, number>();
70 for (const c0 of commits) {
71 byAuthor.set(c0.author, (byAuthor.get(c0.author) || 0) + 1);
72 }
73 const contributors = [...byAuthor.entries()]
74 .sort((a, b) => b[1] - a[1])
75 .slice(0, 10);
76
77 // Commits per day (last 30)
78 const dayCounts = new Map<string, number>();
79 for (const c0 of commits) {
80 const day = c0.date.slice(0, 10);
81 dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
82 }
83 const days: Array<{ date: string; count: number }> = [];
84 const now = new Date();
85 for (let i = 29; i >= 0; i--) {
86 const d = new Date(now);
87 d.setDate(d.getDate() - i);
88 const key = d.toISOString().slice(0, 10);
89 days.push({ date: key, count: dayCounts.get(key) || 0 });
90 }
91 const maxDay = Math.max(1, ...days.map((d) => d.count));
92
93 // Gate health 30d
94 const gateStats = await db
95 .select({
96 status: gateRuns.status,
97 c: sql<number>`count(*)::int`,
98 })
99 .from(gateRuns)
100 .where(eq(gateRuns.repositoryId, repoRow.id))
101 .groupBy(gateRuns.status);
102
103 const statTotals: Record<string, number> = {};
104 let totalRuns = 0;
105 for (const r of gateStats) {
106 statTotals[r.status] = r.c;
107 totalRuns += r.c;
108 }
109 const greenRate =
110 totalRuns === 0
111 ? 100
112 : Math.round(
113 (((statTotals.passed || 0) +
114 (statTotals.repaired || 0) +
115 (statTotals.skipped || 0)) /
116 totalRuns) *
117 100
118 );
119
120 // Issues + PR counts
121 const [issueStats] = await db
122 .select({
123 open: sql<number>`count(*) filter (where ${issues.state} = 'open')::int`,
124 closed: sql<number>`count(*) filter (where ${issues.state} = 'closed')::int`,
125 })
126 .from(issues)
127 .where(eq(issues.repositoryId, repoRow.id));
128
129 const [prStats] = await db
130 .select({
131 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')::int`,
132 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')::int`,
133 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')::int`,
134 })
135 .from(pullRequests)
136 .where(eq(pullRequests.repositoryId, repoRow.id));
137
138 return c.html(
139 <Layout
140 title={`Insights — ${owner}/${repo}`}
141 user={user}
142 notificationCount={unread}
143 >
144 <RepoHeader
145 owner={owner}
146 repo={repo}
147 starCount={repoRow.starCount}
148 forkCount={repoRow.forkCount}
149 currentUser={user?.username || null}
150 />
151 <RepoNav owner={owner} repo={repo} active="insights" />
152 <h3 style="margin-bottom: 16px">Insights</h3>
153
154 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
155 <div class="panel" style="padding: 16px">
156 <div style="font-size: 28px; font-weight: 700; color: var(--green)">
157 {greenRate}%
158 </div>
159 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
160 Green rate
161 </div>
162 </div>
163 <div class="panel" style="padding: 16px">
164 <div style="font-size: 28px; font-weight: 700">{commits.length}</div>
165 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
166 Recent commits
167 </div>
168 </div>
169 <div class="panel" style="padding: 16px">
170 <div style="font-size: 28px; font-weight: 700">
171 {(prStats?.open || 0) + (prStats?.merged || 0) + (prStats?.closed || 0)}
172 </div>
173 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
174 Pull requests
175 </div>
176 </div>
177 <div class="panel" style="padding: 16px">
178 <div style="font-size: 28px; font-weight: 700">
179 {(issueStats?.open || 0) + (issueStats?.closed || 0)}
180 </div>
181 <div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase">
182 Issues
183 </div>
184 </div>
185 </div>
186
187 <div class="dashboard-section">
188 <h3>Commit activity (last 30 days)</h3>
189 <div class="panel" style="padding: 16px">
190 <div style="display: flex; align-items: flex-end; gap: 2px; height: 80px">
191 {days.map((d) => (
192 <div
193 title={`${d.date}: ${d.count} commits`}
194 style={`flex: 1; background: var(--accent); height: ${Math.max(2, (d.count / maxDay) * 80)}px; border-radius: 2px; opacity: ${d.count === 0 ? 0.2 : 1}`}
195 ></div>
196 ))}
197 </div>
198 <div style="display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: var(--text-muted)">
199 <span>{days[0].date}</span>
200 <span>{days[days.length - 1].date}</span>
201 </div>
202 </div>
203 </div>
204
205 <div class="dashboard-section">
206 <h3>Top contributors</h3>
207 <div class="panel">
208 {contributors.length === 0 ? (
209 <div class="panel-empty">No contributors yet.</div>
210 ) : (
211 contributors.map(([author, count]) => {
212 const pct = Math.round(
213 (count / contributors[0][1]) * 100
214 );
215 return (
216 <div class="panel-item">
217 <div style="flex: 1">
218 <div style="font-weight: 500">{author}</div>
219 <div
220 style={`height: 6px; background: var(--accent); border-radius: 3px; margin-top: 4px; width: ${pct}%`}
221 ></div>
222 </div>
223 <div style="font-size: 12px; color: var(--text-muted); width: 80px; text-align: right">
224 {count} commit{count !== 1 ? "s" : ""}
225 </div>
226 </div>
227 );
228 })
229 )}
230 </div>
231 </div>
232 </Layout>
233 );
234});
235
236// ---------- Milestones ----------
237
238insights.get("/:owner/:repo/milestones", async (c) => {
239 const user = c.get("user");
240 const { owner, repo } = c.req.param();
241 const repoRow = await loadRepo(owner, repo);
242 if (!repoRow) return c.notFound();
243 const unread = user ? await getUnreadCount(user.id) : 0;
244 const state = c.req.query("state") || "open";
245
246 const rows = await db
247 .select()
248 .from(milestones)
249 .where(
250 and(
251 eq(milestones.repositoryId, repoRow.id),
252 eq(milestones.state, state)
253 )
254 )
255 .orderBy(desc(milestones.createdAt));
256
257 return c.html(
258 <Layout
259 title={`Milestones — ${owner}/${repo}`}
260 user={user}
261 notificationCount={unread}
262 >
263 <RepoHeader
264 owner={owner}
265 repo={repo}
266 starCount={repoRow.starCount}
267 forkCount={repoRow.forkCount}
268 currentUser={user?.username || null}
269 />
270 <RepoNav owner={owner} repo={repo} active="issues" />
271 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
272 <div class="issue-tabs">
273 <a
274 href={`/${owner}/${repo}/milestones?state=open`}
275 class={state === "open" ? "active" : ""}
276 >
277 Open
278 </a>
279 <a
280 href={`/${owner}/${repo}/milestones?state=closed`}
281 class={state === "closed" ? "active" : ""}
282 >
283 Closed
284 </a>
285 </div>
286 {user && user.id === repoRow.ownerId && (
287 <a
288 href={`/${owner}/${repo}/milestones#new`}
289 class="btn btn-primary btn-sm"
290 >
291 + New milestone
292 </a>
293 )}
294 </div>
295
296 {rows.length === 0 ? (
297 <div class="empty-state">
298 <p>No {state} milestones.</p>
299 </div>
300 ) : (
301 <div class="panel" style="margin-bottom: 24px">
302 {rows.map((m) => (
303 <div class="panel-item" style="justify-content: space-between">
304 <div style="flex: 1">
305 <div style="font-weight: 600">{m.title}</div>
306 {m.description && (
307 <div class="meta" style="margin-top: 2px">{m.description}</div>
308 )}
309 <div class="meta" style="margin-top: 2px">
310 {m.dueDate
311 ? `Due ${new Date(m.dueDate).toLocaleDateString()}`
312 : "No due date"}
313 </div>
314 </div>
315 {user && user.id === repoRow.ownerId && (
316 <div style="display: flex; gap: 4px">
317 {m.state === "open" ? (
318 <form
319 method="POST"
320 action={`/${owner}/${repo}/milestones/${m.id}/close`}
321 >
322 <button type="submit" class="btn btn-sm">
323 Close
324 </button>
325 </form>
326 ) : (
327 <form
328 method="POST"
329 action={`/${owner}/${repo}/milestones/${m.id}/reopen`}
330 >
331 <button type="submit" class="btn btn-sm">
332 Reopen
333 </button>
334 </form>
335 )}
336 <form
337 method="POST"
338 action={`/${owner}/${repo}/milestones/${m.id}/delete`}
339 onsubmit="return confirm('Delete this milestone?')"
340 >
341 <button type="submit" class="btn btn-sm btn-danger">
342 Delete
343 </button>
344 </form>
345 </div>
346 )}
347 </div>
348 ))}
349 </div>
350 )}
351
352 {user && user.id === repoRow.ownerId && (
353 <form
354 id="new"
355 method="POST"
356 action={`/${owner}/${repo}/milestones`}
357 class="panel"
358 style="padding: 16px"
359 >
360 <h3 style="margin-bottom: 12px">Create milestone</h3>
361 <div class="form-group">
362 <label>Title</label>
363 <input type="text" name="title" required />
364 </div>
365 <div class="form-group">
366 <label>Description</label>
367 <textarea name="description" rows={3}></textarea>
368 </div>
369 <div class="form-group">
370 <label>Due date (optional)</label>
371 <input type="date" name="dueDate" />
372 </div>
373 <button type="submit" class="btn btn-primary">
374 Create
375 </button>
376 </form>
377 )}
378 </Layout>
379 );
380});
381
382insights.post("/:owner/:repo/milestones", requireAuth, async (c) => {
383 const user = c.get("user")!;
384 const { owner, repo } = c.req.param();
385 const repoRow = await loadRepo(owner, repo);
386 if (!repoRow) return c.notFound();
387 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/milestones`);
388
389 const body = await c.req.parseBody();
390 const title = String(body.title || "").trim();
391 if (!title) return c.redirect(`/${owner}/${repo}/milestones`);
392 const description = String(body.description || "").trim() || null;
393 const dueDateRaw = String(body.dueDate || "").trim();
394 const dueDate = dueDateRaw ? new Date(dueDateRaw) : null;
395
396 try {
397 await db.insert(milestones).values({
398 repositoryId: repoRow.id,
399 title,
400 description,
401 dueDate,
402 });
403 } catch (err) {
404 console.error("[milestones] create:", err);
405 }
406
407 return c.redirect(`/${owner}/${repo}/milestones`);
408});
409
410insights.post("/:owner/:repo/milestones/:id/close", requireAuth, async (c) => {
411 const user = c.get("user")!;
412 const { owner, repo, id } = c.req.param();
413 const repoRow = await loadRepo(owner, repo);
414 if (!repoRow || repoRow.ownerId !== user.id) {
415 return c.redirect(`/${owner}/${repo}/milestones`);
416 }
417 await db
418 .update(milestones)
419 .set({ state: "closed", closedAt: new Date() })
420 .where(
421 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
422 );
423 return c.redirect(`/${owner}/${repo}/milestones`);
424});
425
426insights.post("/:owner/:repo/milestones/:id/reopen", requireAuth, async (c) => {
427 const user = c.get("user")!;
428 const { owner, repo, id } = c.req.param();
429 const repoRow = await loadRepo(owner, repo);
430 if (!repoRow || repoRow.ownerId !== user.id) {
431 return c.redirect(`/${owner}/${repo}/milestones`);
432 }
433 await db
434 .update(milestones)
435 .set({ state: "open", closedAt: null })
436 .where(
437 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
438 );
439 return c.redirect(`/${owner}/${repo}/milestones?state=closed`);
440});
441
442insights.post("/:owner/:repo/milestones/:id/delete", requireAuth, async (c) => {
443 const user = c.get("user")!;
444 const { owner, repo, id } = c.req.param();
445 const repoRow = await loadRepo(owner, repo);
446 if (!repoRow || repoRow.ownerId !== user.id) {
447 return c.redirect(`/${owner}/${repo}/milestones`);
448 }
449 await db
450 .delete(milestones)
451 .where(
452 and(eq(milestones.id, id), eq(milestones.repositoryId, repoRow.id))
453 );
454 return c.redirect(`/${owner}/${repo}/milestones`);
455});
456
457export default insights;
Addedsrc/routes/notifications.tsx+320−0View fileUnifiedSplit
@@ -0,0 +1,320 @@
1/**
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
10 */
11
12import { Hono } from "hono";
13import { and, desc, eq, isNull, sql } from "drizzle-orm";
14import { db } from "../db";
15import { notifications, repositories, users } from "../db/schema";
16import { Layout } from "../views/layout";
17import { requireAuth, softAuth } from "../middleware/auth";
18import 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> {
29 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;
35 } catch {
36 return 0;
37 }
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 }> = [];
104
105 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 })
119 .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);
148 }
149
150 const unreadCount = rows.filter((r) => !r.readAt).length;
151
152 return c.html(
153 <Layout title="Notifications" user={user}>
154 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
155 <h2>Notifications</h2>
156 {unreadCount > 0 && (
157 <form method="POST" action="/notifications/read-all">
158 <button type="submit" class="btn btn-sm">
159 Mark all as read
160 </button>
161 </form>
162 )}
163 </div>
164
165 <div class="issue-tabs" style="margin-bottom: 16px">
166 <a href="/notifications" class={filter === "all" ? "active" : ""}>
167 All
168 </a>
169 <a
170 href="/notifications?filter=unread"
171 class={filter === "unread" ? "active" : ""}
172 >
173 Unread
174 </a>
175 <a
176 href="/notifications?filter=mentions"
177 class={filter === "mentions" ? "active" : ""}
178 >
179 Mentions
180 </a>
181 </div>
182
183 {rows.length === 0 ? (
184 <div class="empty-state">
185 <h2>Inbox zero</h2>
186 <p>You're all caught up.</p>
187 </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>
209 {n.body && (
210 <div class="notification-desc">{n.body}</div>
211 )}
212 <div class="notification-meta">
213 {n.repoOwner && n.repoName && (
214 <>
215 <a href={`/${n.repoOwner}/${n.repoName}`}>
216 {n.repoOwner}/{n.repoName}
217 </a>
218 <span> · </span>
219 </>
220 )}
221 <span>{formatRelative(n.createdAt)}</span>
222 </div>
223 </div>
224 <div class="notification-actions">
225 {unread && (
226 <form method="POST" action={`/notifications/${n.id}/read`}>
227 <button
228 type="submit"
229 class="btn btn-sm"
230 title="Mark as read"
231 >
232 {"\u2713"}
233 </button>
234 </form>
235 )}
236 <form method="POST" action={`/notifications/${n.id}/delete`}>
237 <button
238 type="submit"
239 class="btn btn-sm"
240 title="Dismiss"
241 >
242 {"\u00D7"}
243 </button>
244 </form>
245 </div>
246 </div>
247 );
248 })}
249 </div>
250 )}
251 </Layout>
252 );
253});
254
255notificationsRoute.post("/notifications/:id/read", requireAuth, async (c) => {
256 const user = c.get("user")!;
257 const id = c.req.param("id");
258 try {
259 await db
260 .update(notifications)
261 .set({ readAt: new Date() })
262 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
263 } catch (err) {
264 console.error("[notifications] mark read:", err);
265 }
266 return c.redirect("/notifications");
267});
268
269notificationsRoute.post("/notifications/read-all", requireAuth, async (c) => {
270 const user = c.get("user")!;
271 try {
272 await db
273 .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);
280 }
281 return c.redirect("/notifications");
282});
283
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");
295});
296
297// ---------- JSON API ----------
298
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")!;
307 try {
308 const rows = await db
309 .select()
310 .from(notifications)
311 .where(eq(notifications.userId, user.id))
312 .orderBy(desc(notifications.createdAt))
313 .limit(100);
314 return c.json(rows);
315 } catch {
316 return c.json([]);
317 }
318});
319
320export default notificationsRoute;
Addedsrc/routes/releases.tsx+494−0View fileUnifiedSplit
@@ -0,0 +1,494 @@
1/**
2 * Releases — tagged snapshots with AI-generated changelogs.
3 *
4 * GET /:owner/:repo/releases — list
5 * GET /:owner/:repo/releases/new — create form (tag + target + AI notes)
6 * POST /:owner/:repo/releases — create release + git tag + changelog
7 * GET /:owner/:repo/releases/:tag — view single release
8 * POST /:owner/:repo/releases/:tag/delete — owner-only delete (also removes git tag)
9 *
10 * Publishing a release fans out `release_published` notifications to starrers.
11 */
12
13import { Hono } from "hono";
14import { eq, and, desc } from "drizzle-orm";
15import { db } from "../db";
16import {
17 releases,
18 repositories,
19 users,
20 stars,
21 repoSettings,
22} from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import {
28 listBranches,
29 listTags,
30 createTag,
31 deleteTag,
32 resolveRef,
33 commitsBetween,
34 getDefaultBranch,
35} from "../git/repository";
36import { generateChangelog } from "../lib/ai-generators";
37import { notifyMany, audit } from "../lib/notify";
38import { renderMarkdown } from "../lib/markdown";
39import { getUnreadCount } from "../lib/unread";
40
41const releasesRoute = new Hono<AuthEnv>();
42releasesRoute.use("*", softAuth);
43
44async function loadRepo(owner: string, repo: string) {
45 const [row] = await db
46 .select({
47 id: repositories.id,
48 name: repositories.name,
49 defaultBranch: repositories.defaultBranch,
50 ownerId: repositories.ownerId,
51 starCount: repositories.starCount,
52 forkCount: repositories.forkCount,
53 forkedFromId: repositories.forkedFromId,
54 })
55 .from(repositories)
56 .innerJoin(users, eq(repositories.ownerId, users.id))
57 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
58 .limit(1);
59 return row;
60}
61
62releasesRoute.get("/:owner/:repo/releases", async (c) => {
63 const user = c.get("user");
64 const { owner, repo } = c.req.param();
65 const repoRow = await loadRepo(owner, repo);
66 if (!repoRow) return c.notFound();
67
68 const rows = await db
69 .select({
70 id: releases.id,
71 tag: releases.tag,
72 name: releases.name,
73 body: releases.body,
74 targetCommit: releases.targetCommit,
75 isDraft: releases.isDraft,
76 isPrerelease: releases.isPrerelease,
77 createdAt: releases.createdAt,
78 publishedAt: releases.publishedAt,
79 authorName: users.username,
80 })
81 .from(releases)
82 .innerJoin(users, eq(releases.authorId, users.id))
83 .where(eq(releases.repositoryId, repoRow.id))
84 .orderBy(desc(releases.createdAt));
85
86 const unread = user ? await getUnreadCount(user.id) : 0;
87
88 return c.html(
89 <Layout
90 title={`Releases — ${owner}/${repo}`}
91 user={user}
92 notificationCount={unread}
93 >
94 <RepoHeader
95 owner={owner}
96 repo={repo}
97 starCount={repoRow.starCount}
98 forkCount={repoRow.forkCount}
99 currentUser={user?.username || null}
100 />
101 <RepoNav owner={owner} repo={repo} active="releases" />
102 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
103 <h3>Releases</h3>
104 {user && user.id === repoRow.ownerId && (
105 <a href={`/${owner}/${repo}/releases/new`} class="btn btn-primary">
106 + Draft release
107 </a>
108 )}
109 </div>
110
111 {rows.length === 0 ? (
112 <div class="empty-state">
113 <h2>No releases yet</h2>
114 <p>Tag a commit and share a changelog with your users.</p>
115 </div>
116 ) : (
117 <div>
118 {rows.map((r, i) => (
119 <div class="release-card">
120 <div class="release-header">
121 <div>
122 <span class="release-name">
123 <a href={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}`}>
124 {r.name}
125 </a>
126 </span>
127 {i === 0 && !r.isDraft && !r.isPrerelease && (
128 <span class="release-tag release-latest" style="margin-left: 8px">
129 Latest
130 </span>
131 )}
132 {r.isDraft && (
133 <span class="badge" style="margin-left: 8px; color: var(--yellow); border-color: var(--yellow)">
134 Draft
135 </span>
136 )}
137 {r.isPrerelease && (
138 <span class="badge" style="margin-left: 8px; color: var(--accent); border-color: var(--accent)">
139 Pre-release
140 </span>
141 )}
142 </div>
143 <span class="release-tag">{r.tag}</span>
144 </div>
145 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px">
146 {r.authorName} released{" "}
147 {new Date(r.publishedAt || r.createdAt).toLocaleDateString()}
148 {" · "}
149 <a href={`/${owner}/${repo}/commit/${r.targetCommit}`}>
150 {r.targetCommit.slice(0, 7)}
151 </a>
152 </div>
153 {r.body && (
154 <div
155 class="markdown-body"
156 style="font-size: 13px; max-height: 200px; overflow: hidden; position: relative"
157 dangerouslySetInnerHTML={{
158 __html: renderMarkdown(r.body.slice(0, 600) + (r.body.length > 600 ? " …" : "")),
159 }}
160 ></div>
161 )}
162 {user && user.id === repoRow.ownerId && (
163 <form
164 method="POST"
165 action={`/${owner}/${repo}/releases/${encodeURIComponent(r.tag)}/delete`}
166 style="margin-top: 12px"
167 onsubmit="return confirm('Delete this release?')"
168 >
169 <button type="submit" class="btn btn-sm btn-danger">
170 Delete
171 </button>
172 </form>
173 )}
174 </div>
175 ))}
176 </div>
177 )}
178 </Layout>
179 );
180});
181
182releasesRoute.get("/:owner/:repo/releases/new", requireAuth, async (c) => {
183 const user = c.get("user")!;
184 const { owner, repo } = c.req.param();
185 const repoRow = await loadRepo(owner, repo);
186 if (!repoRow) return c.notFound();
187 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
188
189 const branches = await listBranches(owner, repo);
190 const tags = await listTags(owner, repo);
191 const unread = await getUnreadCount(user.id);
192 const error = c.req.query("error");
193
194 return c.html(
195 <Layout
196 title={`Draft release — ${owner}/${repo}`}
197 user={user}
198 notificationCount={unread}
199 >
200 <RepoHeader
201 owner={owner}
202 repo={repo}
203 starCount={repoRow.starCount}
204 forkCount={repoRow.forkCount}
205 currentUser={user.username}
206 />
207 <RepoNav owner={owner} repo={repo} active="releases" />
208 <h3>Draft a new release</h3>
209 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
210 <form
211 method="POST"
212 action={`/${owner}/${repo}/releases`}
213 style="max-width: 700px"
214 >
215 <div class="form-group">
216 <label>Tag</label>
217 <input
218 type="text"
219 name="tag"
220 required
221 placeholder="v1.0.0"
222 pattern="[A-Za-z0-9._\\-]+"
223 />
224 </div>
225 <div class="form-group">
226 <label>Target branch / commit</label>
227 <select name="target">
228 {branches.map((b) => (
229 <option value={b} selected={b === repoRow.defaultBranch}>
230 {b}
231 </option>
232 ))}
233 </select>
234 </div>
235 <div class="form-group">
236 <label>Release name</label>
237 <input type="text" name="name" required placeholder="v1.0.0 — the big one" />
238 </div>
239 <div class="form-group">
240 <label>Previous tag (for AI changelog)</label>
241 <select name="previousTag">
242 <option value="">(auto — last tag)</option>
243 {tags.map((t) => (
244 <option value={t.name}>{t.name}</option>
245 ))}
246 </select>
247 </div>
248 <div class="form-group">
249 <label>Notes (leave blank for AI-generated)</label>
250 <textarea name="body" rows={10} placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."></textarea>
251 </div>
252 <div style="display: flex; gap: 12px">
253 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
254 <input type="checkbox" name="isPrerelease" value="1" />
255 Pre-release
256 </label>
257 <label style="display: flex; align-items: center; gap: 6px; font-size: 14px">
258 <input type="checkbox" name="isDraft" value="1" />
259 Save as draft
260 </label>
261 </div>
262 <button type="submit" class="btn btn-primary" style="margin-top: 16px">
263 Publish release
264 </button>
265 </form>
266 </Layout>
267 );
268});
269
270releasesRoute.post("/:owner/:repo/releases", requireAuth, async (c) => {
271 const user = c.get("user")!;
272 const { owner, repo } = c.req.param();
273 const repoRow = await loadRepo(owner, repo);
274 if (!repoRow) return c.notFound();
275 if (repoRow.ownerId !== user.id) return c.redirect(`/${owner}/${repo}/releases`);
276
277 const body = await c.req.parseBody();
278 const tag = String(body.tag || "").trim();
279 const name = String(body.name || "").trim() || tag;
280 const target = String(body.target || repoRow.defaultBranch).trim();
281 const previousTag = String(body.previousTag || "").trim();
282 const notes = String(body.body || "").trim();
283 const isDraft = !!body.isDraft;
284 const isPrerelease = !!body.isPrerelease;
285
286 if (!tag || !/^[A-Za-z0-9._\-]+$/.test(tag)) {
287 return c.redirect(
288 `/${owner}/${repo}/releases/new?error=Invalid+tag+name`
289 );
290 }
291
292 const sha = await resolveRef(owner, repo, target);
293 if (!sha) {
294 return c.redirect(
295 `/${owner}/${repo}/releases/new?error=Could+not+resolve+target`
296 );
297 }
298
299 // Determine previous tag for changelog
300 let autoPrev = previousTag;
301 if (!autoPrev) {
302 const tags = await listTags(owner, repo);
303 autoPrev = tags[0]?.name || "";
304 }
305
306 // Generate changelog body if none provided
307 let finalBody = notes;
308 const [settings] = await db
309 .select()
310 .from(repoSettings)
311 .where(eq(repoSettings.repositoryId, repoRow.id))
312 .limit(1);
313 const aiEnabled = settings ? settings.aiChangelogEnabled : true;
314
315 if (!finalBody && aiEnabled) {
316 const commits = await commitsBetween(owner, repo, autoPrev || null, sha);
317 finalBody = await generateChangelog(`${owner}/${repo}`, autoPrev || null, tag, commits);
318 }
319
320 // Create the git tag (best-effort — if it already exists we reuse)
321 const existing = await resolveRef(owner, repo, `refs/tags/${tag}`);
322 if (!existing) {
323 await createTag(owner, repo, tag, sha, name || tag);
324 }
325
326 // Persist release
327 let releaseId = "";
328 try {
329 const [row] = await db
330 .insert(releases)
331 .values({
332 repositoryId: repoRow.id,
333 authorId: user.id,
334 tag,
335 name,
336 body: finalBody,
337 targetCommit: sha,
338 isDraft,
339 isPrerelease,
340 publishedAt: isDraft ? null : new Date(),
341 })
342 .returning();
343 releaseId = row?.id || "";
344 } catch (err) {
345 console.error("[releases] insert failed:", err);
346 return c.redirect(
347 `/${owner}/${repo}/releases/new?error=Tag+already+published`
348 );
349 }
350
351 // Notify starrers (only on publish)
352 if (!isDraft) {
353 try {
354 const starUsers = await db
355 .select({ userId: stars.userId })
356 .from(stars)
357 .where(eq(stars.repositoryId, repoRow.id));
358 await notifyMany(
359 starUsers.map((s) => s.userId).filter((id) => id !== user.id),
360 {
361 kind: "release_published",
362 title: `${owner}/${repo} ${tag} released`,
363 body: name,
364 url: `/${owner}/${repo}/releases/${encodeURIComponent(tag)}`,
365 repositoryId: repoRow.id,
366 }
367 );
368 } catch {
369 /* ignore */
370 }
371 }
372
373 await audit({
374 userId: user.id,
375 repositoryId: repoRow.id,
376 action: "release.publish",
377 targetType: "release",
378 targetId: releaseId,
379 metadata: { tag, target, isDraft, isPrerelease },
380 });
381
382 return c.redirect(`/${owner}/${repo}/releases/${encodeURIComponent(tag)}`);
383});
384
385releasesRoute.get("/:owner/:repo/releases/:tag", async (c) => {
386 const user = c.get("user");
387 const { owner, repo } = c.req.param();
388 const tag = decodeURIComponent(c.req.param("tag"));
389 const repoRow = await loadRepo(owner, repo);
390 if (!repoRow) return c.notFound();
391
392 const [release] = await db
393 .select({
394 id: releases.id,
395 tag: releases.tag,
396 name: releases.name,
397 body: releases.body,
398 targetCommit: releases.targetCommit,
399 isDraft: releases.isDraft,
400 isPrerelease: releases.isPrerelease,
401 createdAt: releases.createdAt,
402 publishedAt: releases.publishedAt,
403 authorName: users.username,
404 })
405 .from(releases)
406 .innerJoin(users, eq(releases.authorId, users.id))
407 .where(
408 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
409 )
410 .limit(1);
411 if (!release) return c.notFound();
412
413 const unread = user ? await getUnreadCount(user.id) : 0;
414
415 return c.html(
416 <Layout
417 title={`${release.name} — ${owner}/${repo}`}
418 user={user}
419 notificationCount={unread}
420 >
421 <RepoHeader
422 owner={owner}
423 repo={repo}
424 starCount={repoRow.starCount}
425 forkCount={repoRow.forkCount}
426 currentUser={user?.username || null}
427 />
428 <RepoNav owner={owner} repo={repo} active="releases" />
429 <div style="margin-bottom: 8px">
430 <a href={`/${owner}/${repo}/releases`}>{"\u2190"} All releases</a>
431 </div>
432 <div class="release-card">
433 <div class="release-header">
434 <span class="release-name">{release.name}</span>
435 <span class="release-tag">{release.tag}</span>
436 </div>
437 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
438 {release.authorName} released{" "}
439 {new Date(release.publishedAt || release.createdAt).toLocaleString()}
440 {" · "}
441 <a href={`/${owner}/${repo}/commit/${release.targetCommit}`}>
442 {release.targetCommit.slice(0, 7)}
443 </a>
444 {" · "}
445 <a
446 href={`/${owner}/${repo}/archive/${encodeURIComponent(release.tag)}.zip`}
447 >
448 Download
449 </a>
450 </div>
451 {release.body && (
452 <div
453 class="markdown-body"
454 dangerouslySetInnerHTML={{
455 __html: renderMarkdown(release.body),
456 }}
457 ></div>
458 )}
459 </div>
460 </Layout>
461 );
462});
463
464releasesRoute.post(
465 "/:owner/:repo/releases/:tag/delete",
466 requireAuth,
467 async (c) => {
468 const user = c.get("user")!;
469 const { owner, repo } = c.req.param();
470 const tag = decodeURIComponent(c.req.param("tag"));
471 const repoRow = await loadRepo(owner, repo);
472 if (!repoRow) return c.notFound();
473 if (repoRow.ownerId !== user.id) {
474 return c.redirect(`/${owner}/${repo}/releases`);
475 }
476
477 await db
478 .delete(releases)
479 .where(
480 and(eq(releases.repositoryId, repoRow.id), eq(releases.tag, tag))
481 );
482 await deleteTag(owner, repo, tag);
483 await audit({
484 userId: user.id,
485 repositoryId: repoRow.id,
486 action: "release.delete",
487 targetType: "release",
488 metadata: { tag },
489 });
490 return c.redirect(`/${owner}/${repo}/releases`);
491 }
492);
493
494export default releasesRoute;
Addedsrc/routes/search.tsx+290−0View fileUnifiedSplit
@@ -0,0 +1,290 @@
1/**
2 * Global search — across repos, users, issues, PRs.
3 *
4 * GET /search?q=...&type=repos|users|issues|prs
5 *
6 * Text search uses Postgres ILIKE — good enough for the scale GlueCron is at
7 * today. If traffic grows, swap in pgvector + AI embeddings.
8 */
9
10import { Hono } from "hono";
11import { and, desc, eq, or, sql } from "drizzle-orm";
12import { db } from "../db";
13import {
14 issues,
15 pullRequests,
16 repositories,
17 users,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoCard } from "../views/components";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { getUnreadCount } from "../lib/unread";
24
25const search = new Hono<AuthEnv>();
26search.use("*", softAuth);
27
28search.get("/search", async (c) => {
29 const user = c.get("user");
30 const q = (c.req.query("q") || "").trim();
31 const type = c.req.query("type") || "repos";
32 const unread = user ? await getUnreadCount(user.id) : 0;
33
34 let repoHits: Array<{
35 repo: typeof repositories.$inferSelect;
36 ownerName: string;
37 }> = [];
38 let userHits: Array<typeof users.$inferSelect> = [];
39 let issueHits: Array<{
40 id: string;
41 number: number;
42 title: string;
43 state: string;
44 repoName: string;
45 repoOwner: string;
46 }> = [];
47 let prHits: Array<{
48 id: string;
49 number: number;
50 title: string;
51 state: string;
52 repoName: string;
53 repoOwner: string;
54 }> = [];
55
56 if (q) {
57 const pat = `%${q}%`;
58 if (type === "repos") {
59 const results = await db
60 .select({ repo: repositories, ownerName: users.username })
61 .from(repositories)
62 .innerJoin(users, eq(repositories.ownerId, users.id))
63 .where(
64 and(
65 eq(repositories.isPrivate, false),
66 sql`(${repositories.name} ILIKE ${pat} OR ${repositories.description} ILIKE ${pat})`
67 )
68 )
69 .orderBy(desc(repositories.starCount))
70 .limit(30);
71 repoHits = results;
72 } else if (type === "users") {
73 userHits = await db
74 .select()
75 .from(users)
76 .where(
77 sql`(${users.username} ILIKE ${pat} OR ${users.displayName} ILIKE ${pat})`
78 )
79 .limit(30);
80 } else if (type === "issues") {
81 issueHits = await db
82 .select({
83 id: issues.id,
84 number: issues.number,
85 title: issues.title,
86 state: issues.state,
87 repoName: repositories.name,
88 repoOwner: users.username,
89 })
90 .from(issues)
91 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
92 .innerJoin(users, eq(repositories.ownerId, users.id))
93 .where(
94 and(
95 eq(repositories.isPrivate, false),
96 sql`(${issues.title} ILIKE ${pat} OR ${issues.body} ILIKE ${pat})`
97 )
98 )
99 .orderBy(desc(issues.updatedAt))
100 .limit(30);
101 } else if (type === "prs") {
102 prHits = await db
103 .select({
104 id: pullRequests.id,
105 number: pullRequests.number,
106 title: pullRequests.title,
107 state: pullRequests.state,
108 repoName: repositories.name,
109 repoOwner: users.username,
110 })
111 .from(pullRequests)
112 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
113 .innerJoin(users, eq(repositories.ownerId, users.id))
114 .where(
115 and(
116 eq(repositories.isPrivate, false),
117 sql`(${pullRequests.title} ILIKE ${pat} OR ${pullRequests.body} ILIKE ${pat})`
118 )
119 )
120 .orderBy(desc(pullRequests.updatedAt))
121 .limit(30);
122 }
123 }
124
125 const tab = (id: string, label: string) => (
126 <a
127 href={`/search?q=${encodeURIComponent(q)}&type=${id}`}
128 class={type === id ? "active" : ""}
129 >
130 {label}
131 </a>
132 );
133
134 return c.html(
135 <Layout
136 title={q ? `Search — ${q}` : "Search"}
137 user={user}
138 notificationCount={unread}
139 >
140 <form method="GET" action="/search" style="margin-bottom: 16px">
141 <input
142 type="hidden"
143 name="type"
144 value={type}
145 />
146 <input
147 type="search"
148 name="q"
149 value={q}
150 placeholder="Search repositories, users, issues, PRs…"
151 style="width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
152 autofocus
153 />
154 </form>
155
156 <div class="issue-tabs" style="margin-bottom: 20px">
157 {tab("repos", "Repositories")}
158 {tab("users", "Users")}
159 {tab("issues", "Issues")}
160 {tab("prs", "Pull requests")}
161 </div>
162
163 {!q && (
164 <div class="empty-state">
165 <p>Type to search across GlueCron.</p>
166 </div>
167 )}
168
169 {q && type === "repos" && (
170 repoHits.length === 0 ? (
171 <div class="empty-state"><p>No repositories match "{q}"</p></div>
172 ) : (
173 <div class="card-grid">
174 {repoHits.map(({ repo, ownerName }) => (
175 <RepoCard repo={repo} ownerName={ownerName} />
176 ))}
177 </div>
178 )
179 )}
180
181 {q && type === "users" && (
182 userHits.length === 0 ? (
183 <div class="empty-state"><p>No users match "{q}"</p></div>
184 ) : (
185 <div class="panel">
186 {userHits.map((u) => (
187 <div class="panel-item">
188 <div class="dot blue"></div>
189 <div style="flex: 1">
190 <a href={`/${u.username}`} style="font-weight: 600">
191 {u.displayName || u.username}
192 </a>
193 <div class="meta">@{u.username}</div>
194 {u.bio && <div class="meta">{u.bio}</div>}
195 </div>
196 </div>
197 ))}
198 </div>
199 )
200 )}
201
202 {q && type === "issues" && (
203 issueHits.length === 0 ? (
204 <div class="empty-state"><p>No issues match "{q}"</p></div>
205 ) : (
206 <div class="panel">
207 {issueHits.map((i) => (
208 <div class="panel-item">
209 <div
210 class={`dot ${i.state === "open" ? "green" : "yellow"}`}
211 ></div>
212 <div style="flex: 1">
213 <a
214 href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}
215 >
216 {i.title}
217 </a>
218 <div class="meta">
219 {i.repoOwner}/{i.repoName}#{i.number} · {i.state}
220 </div>
221 </div>
222 </div>
223 ))}
224 </div>
225 )
226 )}
227
228 {q && type === "prs" && (
229 prHits.length === 0 ? (
230 <div class="empty-state"><p>No pull requests match "{q}"</p></div>
231 ) : (
232 <div class="panel">
233 {prHits.map((pr) => (
234 <div class="panel-item">
235 <div
236 class={`dot ${pr.state === "open" ? "green" : pr.state === "merged" ? "blue" : "yellow"}`}
237 ></div>
238 <div style="flex: 1">
239 <a
240 href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}
241 >
242 {pr.title}
243 </a>
244 <div class="meta">
245 {pr.repoOwner}/{pr.repoName}#{pr.number} · {pr.state}
246 </div>
247 </div>
248 </div>
249 ))}
250 </div>
251 )
252 )}
253 </Layout>
254 );
255});
256
257// Keyboard shortcuts help page — linked from Cmd+? in nav
258search.get("/shortcuts", async (c) => {
259 const user = c.get("user");
260 const unread = user ? await getUnreadCount(user.id) : 0;
261 const shortcuts: Array<{ keys: string; desc: string }> = [
262 { keys: "/", desc: "Focus global search" },
263 { keys: "Cmd/Ctrl + K", desc: "Open AI assistant" },
264 { keys: "g d", desc: "Go to dashboard" },
265 { keys: "g n", desc: "Go to notifications" },
266 { keys: "g e", desc: "Go to explore" },
267 { keys: "n", desc: "New repository" },
268 { keys: "?", desc: "Show this help" },
269 ];
270
271 return c.html(
272 <Layout title="Keyboard shortcuts" user={user} notificationCount={unread}>
273 <h2 style="margin-bottom: 16px">Keyboard shortcuts</h2>
274 <div class="panel">
275 {shortcuts.map((s) => (
276 <div class="panel-item" style="justify-content: space-between">
277 <span>{s.desc}</span>
278 <kbd
279 style="font-family: var(--font-mono); background: var(--bg-tertiary); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px"
280 >
281 {s.keys}
282 </kbd>
283 </div>
284 ))}
285 </div>
286 </Layout>
287 );
288});
289
290export default search;
Modifiedsrc/routes/web.tsx+21−36View fileUnifiedSplit
@@ -52,35 +52,8 @@ web.get("/", async (c) => {
5252 const user = c.get("user");
5353
5454 if (user) {
55 // Show user's repos
56 const repos = await db
57 .select()
58 .from(repositories)
59 .where(eq(repositories.ownerId, user.id))
60 .orderBy(desc(repositories.updatedAt));
61
62 return c.html(
63 <Layout title="Dashboard" user={user}>
64 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
65 <h2>Your repositories</h2>
66 <a href="/new" class="btn btn-primary">
67 + New repository
68 </a>
69 </div>
70 {repos.length === 0 ? (
71 <div class="empty-state">
72 <h2>No repositories yet</h2>
73 <p>Create your first repository to get started.</p>
74 </div>
75 ) : (
76 <div class="card-grid">
77 {repos.map((repo) => (
78 <RepoCard repo={repo} ownerName={user.username} />
79 ))}
80 </div>
81 )}
82 </Layout>
83 );
55 const { renderDashboard } = await import("./dashboard");
56 return renderDashboard(c);
8457 }
8558
8659 return c.html(
@@ -186,13 +159,25 @@ web.post("/new", requireAuth, async (c) => {
186159
187160 const diskPath = await initBareRepo(user.username, name);
188161
189 await db.insert(repositories).values({
190 name,
191 ownerId: user.id,
192 description: description || null,
193 isPrivate,
194 diskPath,
195 });
162 const [newRepo] = await db
163 .insert(repositories)
164 .values({
165 name,
166 ownerId: user.id,
167 description: description || null,
168 isPrivate,
169 diskPath,
170 })
171 .returning();
172
173 if (newRepo) {
174 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
175 await bootstrapRepository({
176 repositoryId: newRepo.id,
177 ownerUserId: user.id,
178 defaultBranch: "main",
179 });
180 }
196181
197182 return c.redirect(`/${user.username}/${name}`);
198183});
Modifiedsrc/views/components.tsx+29−1View fileUnifiedSplit
@@ -60,7 +60,14 @@ export const RepoHeader: FC<{
6060export const RepoNav: FC<{
6161 owner: string;
6262 repo: string;
63 active: "code" | "commits" | "issues" | "pulls";
63 active:
64 | "code"
65 | "commits"
66 | "issues"
67 | "pulls"
68 | "releases"
69 | "gates"
70 | "insights";
6471}> = ({ owner, repo, active }) => (
6572 <div class="repo-nav">
6673 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
@@ -84,6 +91,27 @@ export const RepoNav: FC<{
8491 >
8592 Commits
8693 </a>
94 <a
95 href={`/${owner}/${repo}/releases`}
96 class={active === "releases" ? "active" : ""}
97 >
98 Releases
99 </a>
100 <a
101 href={`/${owner}/${repo}/gates`}
102 class={active === "gates" ? "active" : ""}
103 >
104 {"\u25CF"} Gates
105 </a>
106 <a
107 href={`/${owner}/${repo}/insights`}
108 class={active === "insights" ? "active" : ""}
109 >
110 Insights
111 </a>
112 <a href={`/${owner}/${repo}/ask`} style="margin-left: auto; color: #bc8cff">
113 {"\u2728"} Ask AI
114 </a>
87115 </div>
88116);
89117
Modifiedsrc/views/layout.tsx+324−2View fileUnifiedSplit
@@ -3,8 +3,12 @@ import type { User } from "../db/schema";
33import { hljsThemeCss } from "../lib/highlight";
44
55export const Layout: FC<
6 PropsWithChildren<{ title?: string; user?: User | null }>
7> = ({ children, title, user }) => {
6 PropsWithChildren<{
7 title?: string;
8 user?: User | null;
9 notificationCount?: number;
10 }>
11> = ({ children, title, user, notificationCount }) => {
812 return (
913 <html lang="en">
1014 <head>
@@ -20,12 +24,37 @@ export const Layout: FC<
2024 <a href="/" class="logo">
2125 gluecron
2226 </a>
27 <div class="nav-search">
28 <form method="GET" action="/search">
29 <input
30 type="search"
31 name="q"
32 placeholder="Search (press /)"
33 aria-label="Search"
34 />
35 </form>
36 </div>
2337 <div class="nav-right">
2438 <a href="/explore" class="nav-link">
2539 Explore
2640 </a>
2741 {user ? (
2842 <>
43 <a href="/ask" class="nav-link" title="Ask AI (Cmd+K)">
44 {"\u2728"} Ask
45 </a>
46 <a
47 href="/notifications"
48 class="nav-link nav-notifications"
49 title="Notifications"
50 >
51 {"\u2709"}
52 {notificationCount !== undefined && notificationCount > 0 && (
53 <span class="nav-badge">
54 {notificationCount > 99 ? "99+" : notificationCount}
55 </span>
56 )}
57 </a>
2958 <a href="/new" class="btn btn-sm btn-primary">
3059 + New
3160 </a>
@@ -56,11 +85,55 @@ export const Layout: FC<
5685 <footer>
5786 <span>gluecron — AI-native code intelligence</span>
5887 </footer>
88 <script>{navScript}</script>
5989 </body>
6090 </html>
6191 );
6292};
6393
94const navScript = `
95 (function(){
96 var chord = null;
97 var chordTimer = null;
98 function isTyping(t){
99 t = t || {};
100 var tag = (t.tagName || '').toLowerCase();
101 return tag === 'input' || tag === 'textarea' || t.isContentEditable;
102 }
103 document.addEventListener('keydown', function(e){
104 if (isTyping(e.target)) return;
105 // Single key shortcuts
106 if (e.key === '/') {
107 var el = document.querySelector('.nav-search input');
108 if (el) { e.preventDefault(); el.focus(); return; }
109 }
110 if ((e.metaKey||e.ctrlKey) && e.key.toLowerCase() === 'k') {
111 e.preventDefault(); window.location.href = '/ask'; return;
112 }
113 if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
114 e.preventDefault(); window.location.href = '/shortcuts'; return;
115 }
116 if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
117 e.preventDefault(); window.location.href = '/new'; return;
118 }
119 // "g" chord
120 if (e.key === 'g' && !e.ctrlKey && !e.metaKey) {
121 chord = 'g';
122 clearTimeout(chordTimer);
123 chordTimer = setTimeout(function(){ chord = null; }, 1200);
124 return;
125 }
126 if (chord === 'g') {
127 if (e.key === 'd') { e.preventDefault(); window.location.href = '/dashboard'; }
128 else if (e.key === 'n') { e.preventDefault(); window.location.href = '/notifications'; }
129 else if (e.key === 'e') { e.preventDefault(); window.location.href = '/explore'; }
130 else if (e.key === 'a') { e.preventDefault(); window.location.href = '/ask'; }
131 chord = null;
132 }
133 });
134 })();
135`;
136
64137const css = `
65138 :root {
66139 --bg: #0d1117;
@@ -535,4 +608,253 @@ const css = `
535608
536609 /* Search */
537610 .search-results .diff-file { margin-bottom: 12px; }
611
612 /* Nav — search + ask + notifications badge */
613 .nav-search { flex: 1; max-width: 320px; margin: 0 16px; }
614 .nav-search form { width: 100%; }
615 .nav-search input {
616 width: 100%;
617 padding: 6px 10px;
618 background: var(--bg);
619 border: 1px solid var(--border);
620 border-radius: var(--radius);
621 color: var(--text);
622 font-size: 13px;
623 }
624 .nav-search input::placeholder { color: var(--text-muted); }
625 .nav-search input:focus { outline: none; border-color: var(--accent); }
626 .nav-notifications { position: relative; display: inline-flex; align-items: center; }
627 .nav-badge {
628 position: absolute;
629 top: -6px;
630 right: -10px;
631 min-width: 18px;
632 height: 18px;
633 padding: 0 5px;
634 border-radius: 9px;
635 background: var(--red);
636 color: #fff;
637 font-size: 11px;
638 font-weight: 700;
639 display: flex;
640 align-items: center;
641 justify-content: center;
642 }
643
644 /* Notifications list */
645 .notification-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
646 .notification-item {
647 display: flex;
648 gap: 12px;
649 align-items: flex-start;
650 padding: 12px 16px;
651 border-bottom: 1px solid var(--border);
652 background: var(--bg);
653 }
654 .notification-item.unread { background: rgba(56, 139, 253, 0.06); }
655 .notification-item:last-child { border-bottom: none; }
656 .notification-badge {
657 flex-shrink: 0;
658 padding: 2px 8px;
659 border-radius: 10px;
660 font-size: 11px;
661 font-weight: 600;
662 border: 1px solid;
663 text-transform: lowercase;
664 }
665 .notification-body { flex: 1; min-width: 0; }
666 .notification-title { font-size: 14px; font-weight: 500; margin-bottom: 2px; }
667 .notification-title a { color: var(--text); }
668 .notification-title a:hover { color: var(--text-link); }
669 .notification-desc { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
670 .notification-meta { font-size: 12px; color: var(--text-muted); }
671 .notification-meta a { color: var(--text-link); }
672 .notification-actions { display: flex; gap: 4px; align-items: center; }
673 .notification-actions .btn { padding: 2px 8px; font-size: 12px; line-height: 1; }
674
675 /* Dashboard */
676 .dashboard-grid {
677 display: grid;
678 grid-template-columns: 2fr 1fr;
679 gap: 24px;
680 }
681 .dashboard-section { margin-bottom: 24px; }
682 .dashboard-section h3 {
683 font-size: 14px;
684 text-transform: uppercase;
685 letter-spacing: 0.5px;
686 color: var(--text-muted);
687 margin-bottom: 12px;
688 display: flex;
689 justify-content: space-between;
690 align-items: center;
691 }
692 .dashboard-section h3 a { font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; }
693 .panel {
694 border: 1px solid var(--border);
695 border-radius: var(--radius);
696 background: var(--bg-secondary);
697 overflow: hidden;
698 }
699 .panel-item {
700 display: flex;
701 align-items: flex-start;
702 gap: 10px;
703 padding: 10px 14px;
704 border-bottom: 1px solid var(--border);
705 font-size: 13px;
706 }
707 .panel-item:last-child { border-bottom: none; }
708 .panel-item .dot {
709 width: 8px; height: 8px; border-radius: 50%;
710 margin-top: 6px; flex-shrink: 0; background: var(--text-muted);
711 }
712 .panel-item .dot.green { background: var(--green); }
713 .panel-item .dot.red { background: var(--red); }
714 .panel-item .dot.yellow { background: var(--yellow); }
715 .panel-item .dot.blue { background: var(--accent); }
716 .panel-item .meta { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
717 .panel-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; }
718
719 /* AI Ask chat */
720 .ask-container { max-width: 900px; margin: 0 auto; }
721 .chat-log {
722 border: 1px solid var(--border);
723 border-radius: var(--radius);
724 background: var(--bg-secondary);
725 padding: 16px;
726 margin-bottom: 16px;
727 min-height: 200px;
728 max-height: 60vh;
729 overflow-y: auto;
730 }
731 .chat-message {
732 padding: 10px 14px;
733 border-radius: var(--radius);
734 margin-bottom: 10px;
735 white-space: pre-wrap;
736 word-wrap: break-word;
737 font-size: 14px;
738 line-height: 1.5;
739 }
740 .chat-message.user { background: var(--bg-tertiary); border: 1px solid var(--border); }
741 .chat-message.assistant { background: rgba(188, 140, 255, 0.08); border: 1px solid rgba(188, 140, 255, 0.3); }
742 .chat-message .role {
743 font-size: 11px;
744 font-weight: 600;
745 text-transform: uppercase;
746 letter-spacing: 0.5px;
747 color: var(--text-muted);
748 margin-bottom: 6px;
749 }
750 .chat-form textarea {
751 width: 100%;
752 min-height: 80px;
753 padding: 10px 12px;
754 background: var(--bg);
755 border: 1px solid var(--border);
756 border-radius: var(--radius);
757 color: var(--text);
758 font-family: var(--font-sans);
759 font-size: 14px;
760 resize: vertical;
761 }
762 .chat-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
763 .chat-cited {
764 display: inline-block;
765 font-family: var(--font-mono);
766 font-size: 11px;
767 padding: 1px 6px;
768 background: var(--bg-tertiary);
769 border: 1px solid var(--border);
770 border-radius: 3px;
771 margin-right: 4px;
772 }
773
774 /* Releases */
775 .release-card {
776 border: 1px solid var(--border);
777 border-radius: var(--radius);
778 background: var(--bg-secondary);
779 padding: 16px;
780 margin-bottom: 16px;
781 }
782 .release-header {
783 display: flex;
784 justify-content: space-between;
785 align-items: baseline;
786 margin-bottom: 8px;
787 gap: 12px;
788 flex-wrap: wrap;
789 }
790 .release-name { font-size: 18px; font-weight: 600; }
791 .release-tag {
792 font-family: var(--font-mono);
793 font-size: 12px;
794 padding: 2px 8px;
795 background: var(--bg-tertiary);
796 border: 1px solid var(--border);
797 border-radius: var(--radius);
798 color: var(--green);
799 }
800 .release-latest { background: var(--green); color: var(--bg); border-color: var(--green); }
801
802 /* Gate runs */
803 .gate-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
804 .gate-run-row {
805 display: flex;
806 align-items: center;
807 gap: 12px;
808 padding: 10px 14px;
809 border-bottom: 1px solid var(--border);
810 font-size: 13px;
811 }
812 .gate-run-row:last-child { border-bottom: none; }
813 .gate-status {
814 display: inline-flex;
815 align-items: center;
816 gap: 4px;
817 padding: 2px 8px;
818 border-radius: 10px;
819 font-size: 11px;
820 font-weight: 600;
821 text-transform: uppercase;
822 }
823 .gate-status.passed { background: rgba(63, 185, 80, 0.15); color: var(--green); border: 1px solid var(--green); }
824 .gate-status.failed { background: rgba(248, 81, 73, 0.15); color: var(--red); border: 1px solid var(--red); }
825 .gate-status.repaired { background: rgba(188, 140, 255, 0.15); color: #bc8cff; border: 1px solid #bc8cff; }
826 .gate-status.skipped { background: var(--bg-tertiary); color: var(--text-muted); border: 1px solid var(--border); }
827 .gate-status.running, .gate-status.pending { background: rgba(210, 153, 34, 0.15); color: var(--yellow); border: 1px solid var(--yellow); }
828
829 /* Search */
830 .search-filters { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
831 .search-hit {
832 border: 1px solid var(--border);
833 border-radius: var(--radius);
834 padding: 12px 16px;
835 background: var(--bg-secondary);
836 margin-bottom: 8px;
837 }
838 .search-hit h4 { font-size: 14px; margin-bottom: 4px; }
839 .search-hit .hit-path { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
840 .search-hit pre { margin-top: 8px; font-size: 12px; }
841
842 /* Toasts (prepared for future UI hooks) */
843 .toast-container {
844 position: fixed; bottom: 24px; right: 24px;
845 z-index: 50; display: flex; flex-direction: column; gap: 8px;
846 }
847
848 /* Mobile */
849 @media (max-width: 768px) {
850 header nav { flex-wrap: wrap; gap: 8px; }
851 .nav-search { max-width: 100%; margin: 8px 0; order: 3; width: 100%; }
852 .nav-right { flex-wrap: wrap; gap: 8px; }
853 main { padding: 16px; }
854 .dashboard-grid { grid-template-columns: 1fr; }
855 .card-grid { grid-template-columns: 1fr; }
856 .user-profile { flex-direction: column; gap: 16px; }
857 .repo-header { flex-wrap: wrap; }
858 .repo-nav { overflow-x: auto; }
859 }
538860`;
539861