Commit9a78aa4unknown_key
Merge pull request #3 from ccantynz-alt/claude/resume-previous-work-KzyLw
Merge pull request #3 from ccantynz-alt/claude/resume-previous-work-KzyLw Ship-ready: AI review, green gates, auto-merge, performance, Fly.io deploy
20 files changed+1462−1799a78aa42b381193b420d3ef27c210d78c0eed7cb
20 changed files+1462−179
Added.dockerignore+49−0View fileUnifiedSplit
@@ -0,0 +1,49 @@
1# Version control
2.git
3.gitignore
4.gitattributes
5
6# Dependencies (reinstalled in Docker)
7node_modules
8
9# Bare git repositories (runtime data, not source)
10repos
11
12# Environment / secrets
13.env
14.env.*
15!.env.example
16
17# Build / generated output
18dist
19build
20*.js.map
21
22# Documentation
23*.md
24LICENSE
25
26# Tests
27src/__tests__
28**/*.test.ts
29**/*.spec.ts
30
31# Local tooling config
32drizzle.config.ts
33tsconfig.json
34.editorconfig
35.eslintrc*
36.prettierrc*
37biome.json
38
39# Docker files themselves
40Dockerfile
41.dockerignore
42
43# OS / editor artifacts
44.DS_Store
45Thumbs.db
46.idea
47.vscode
48*.swp
49*.swo
Modified.env.example+2−0View fileUnifiedSplit
@@ -2,4 +2,6 @@ DATABASE_URL=postgresql://user:password@host/gluecron
22GIT_REPOS_PATH=./repos
33PORT=3000
44GATETEST_URL=https://gatetest.ai/api/scan/run
5GATETEST_API_KEY=
56CRONTECH_DEPLOY_URL=https://crontech.ai/api/trpc/tenant.deploy
7ANTHROPIC_API_KEY=
AddedDockerfile+45−0View fileUnifiedSplit
@@ -0,0 +1,45 @@
1# ---- build stage ----
2FROM oven/bun:1 AS builder
3
4WORKDIR /app
5
6# Copy lockfile and manifest first for layer caching
7COPY package.json bun.lock ./
8
9# Install production dependencies only
10RUN bun install --frozen-lockfile --production
11
12# ---- production stage ----
13# oven/bun:1-debian is based on Debian so apt is available
14FROM oven/bun:1-debian AS runner
15
16WORKDIR /app
17
18# Install git (required for git CLI subprocess calls)
19RUN apt-get update \
20 && apt-get install -y --no-install-recommends git \
21 && rm -rf /var/lib/apt/lists/*
22
23# Copy production node_modules from builder
24COPY --from=builder /app/node_modules ./node_modules
25
26# Copy application source and migration files
27COPY src/ ./src/
28COPY drizzle/ ./drizzle/
29COPY package.json ./
30
31# Create the repos directory and give ownership to the bun user
32RUN mkdir -p /app/repos \
33 && chown -R bun:bun /app
34
35# Run as non-root user (provided by the base image)
36USER bun
37
38# Default environment variables
39ENV GIT_REPOS_PATH=/app/repos \
40 PORT=3000 \
41 NODE_ENV=production
42
43EXPOSE 3000
44
45CMD ["bun", "run", "src/index.ts"]
Modifiedbun.lock+9−0View fileUnifiedSplit
@@ -5,6 +5,7 @@
55 "": {
66 "name": "gluecron",
77 "dependencies": {
8 "@anthropic-ai/sdk": "^0.88.0",
89 "@hono/node-server": "^1.13.0",
910 "@neondatabase/serverless": "^0.10.0",
1011 "drizzle-orm": "^0.39.0",
@@ -20,6 +21,10 @@
2021 },
2122 },
2223 "packages": {
24 "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.88.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-QQOtB5U9ZBJQj6y1ICmDZl14LWa4JCiJRoihI+0yuZ4OjbONrakP0yLwPv4DJFb3VYCtQM31bTOpCBMs2zghPw=="],
25
26 "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
27
2328 "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
2429
2530 "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -110,6 +115,8 @@
110115
111116 "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
112117
118 "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
119
113120 "marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
114121
115122 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -144,6 +151,8 @@
144151
145152 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
146153
154 "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
155
147156 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
148157
149158 "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
Addeddrizzle/0000_initial.sql+248−0View fileUnifiedSplit
@@ -0,0 +1,248 @@
1-- Gluecron initial migration
2-- Generated manually to match src/db/schema.ts
3
4--> statement-breakpoint
5CREATE TABLE IF NOT EXISTS "users" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
7 "username" text NOT NULL,
8 "email" text NOT NULL,
9 "display_name" text,
10 "password_hash" text NOT NULL,
11 "avatar_url" text,
12 "bio" text,
13 "created_at" timestamp DEFAULT now() NOT NULL,
14 "updated_at" timestamp DEFAULT now() NOT NULL,
15 CONSTRAINT "users_username_unique" UNIQUE ("username"),
16 CONSTRAINT "users_email_unique" UNIQUE ("email")
17);
18
19--> statement-breakpoint
20CREATE TABLE IF NOT EXISTS "sessions" (
21 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
22 "user_id" uuid NOT NULL,
23 "token" text NOT NULL,
24 "expires_at" timestamp NOT NULL,
25 "created_at" timestamp DEFAULT now() NOT NULL,
26 CONSTRAINT "sessions_token_unique" UNIQUE ("token"),
27 CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
28);
29
30--> statement-breakpoint
31CREATE TABLE IF NOT EXISTS "repositories" (
32 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
33 "name" text NOT NULL,
34 "owner_id" uuid NOT NULL,
35 "description" text,
36 "is_private" boolean DEFAULT false NOT NULL,
37 "default_branch" text DEFAULT 'main' NOT NULL,
38 "disk_path" text NOT NULL,
39 "forked_from_id" uuid,
40 "created_at" timestamp DEFAULT now() NOT NULL,
41 "updated_at" timestamp DEFAULT now() NOT NULL,
42 "pushed_at" timestamp,
43 "star_count" integer DEFAULT 0 NOT NULL,
44 "fork_count" integer DEFAULT 0 NOT NULL,
45 "issue_count" integer DEFAULT 0 NOT NULL,
46 CONSTRAINT "repositories_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "users" ("id"),
47 CONSTRAINT "repositories_forked_from_id_repositories_id_fk" FOREIGN KEY ("forked_from_id") REFERENCES "repositories" ("id") ON DELETE SET NULL
48);
49
50--> statement-breakpoint
51CREATE UNIQUE INDEX IF NOT EXISTS "repos_owner_name" ON "repositories" ("owner_id", "name");
52
53--> statement-breakpoint
54CREATE TABLE IF NOT EXISTS "stars" (
55 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
56 "user_id" uuid NOT NULL,
57 "repository_id" uuid NOT NULL,
58 "created_at" timestamp DEFAULT now() NOT NULL,
59 CONSTRAINT "stars_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
60 CONSTRAINT "stars_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
61);
62
63--> statement-breakpoint
64CREATE UNIQUE INDEX IF NOT EXISTS "stars_user_repo" ON "stars" ("user_id", "repository_id");
65
66--> statement-breakpoint
67CREATE TABLE IF NOT EXISTS "issues" (
68 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
69 "number" serial NOT NULL,
70 "repository_id" uuid NOT NULL,
71 "author_id" uuid NOT NULL,
72 "title" text NOT NULL,
73 "body" text,
74 "state" text DEFAULT 'open' NOT NULL,
75 "created_at" timestamp DEFAULT now() NOT NULL,
76 "updated_at" timestamp DEFAULT now() NOT NULL,
77 "closed_at" timestamp,
78 CONSTRAINT "issues_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
79 CONSTRAINT "issues_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
80);
81
82--> statement-breakpoint
83CREATE INDEX IF NOT EXISTS "issues_repo_state" ON "issues" ("repository_id", "state");
84
85--> statement-breakpoint
86CREATE INDEX IF NOT EXISTS "issues_repo_number" ON "issues" ("repository_id", "number");
87
88--> statement-breakpoint
89CREATE TABLE IF NOT EXISTS "issue_comments" (
90 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
91 "issue_id" uuid NOT NULL,
92 "author_id" uuid NOT NULL,
93 "body" text NOT NULL,
94 "created_at" timestamp DEFAULT now() NOT NULL,
95 "updated_at" timestamp DEFAULT now() NOT NULL,
96 CONSTRAINT "issue_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "issues" ("id") ON DELETE CASCADE,
97 CONSTRAINT "issue_comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
98);
99
100--> statement-breakpoint
101CREATE INDEX IF NOT EXISTS "comments_issue" ON "issue_comments" ("issue_id");
102
103--> statement-breakpoint
104CREATE TABLE IF NOT EXISTS "labels" (
105 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
106 "repository_id" uuid NOT NULL,
107 "name" text NOT NULL,
108 "color" text DEFAULT '#8b949e' NOT NULL,
109 "description" text,
110 CONSTRAINT "labels_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
111);
112
113--> statement-breakpoint
114CREATE UNIQUE INDEX IF NOT EXISTS "labels_repo_name" ON "labels" ("repository_id", "name");
115
116--> statement-breakpoint
117CREATE TABLE IF NOT EXISTS "issue_labels" (
118 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
119 "issue_id" uuid NOT NULL,
120 "label_id" uuid NOT NULL,
121 CONSTRAINT "issue_labels_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "issues" ("id") ON DELETE CASCADE,
122 CONSTRAINT "issue_labels_label_id_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "labels" ("id") ON DELETE CASCADE
123);
124
125--> statement-breakpoint
126CREATE UNIQUE INDEX IF NOT EXISTS "issue_labels_unique" ON "issue_labels" ("issue_id", "label_id");
127
128--> statement-breakpoint
129CREATE TABLE IF NOT EXISTS "pull_requests" (
130 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
131 "number" serial NOT NULL,
132 "repository_id" uuid NOT NULL,
133 "author_id" uuid NOT NULL,
134 "title" text NOT NULL,
135 "body" text,
136 "state" text DEFAULT 'open' NOT NULL,
137 "base_branch" text NOT NULL,
138 "head_branch" text NOT NULL,
139 "merged_at" timestamp,
140 "merged_by" uuid,
141 "created_at" timestamp DEFAULT now() NOT NULL,
142 "updated_at" timestamp DEFAULT now() NOT NULL,
143 "closed_at" timestamp,
144 CONSTRAINT "pull_requests_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
145 CONSTRAINT "pull_requests_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id"),
146 CONSTRAINT "pull_requests_merged_by_users_id_fk" FOREIGN KEY ("merged_by") REFERENCES "users" ("id")
147);
148
149--> statement-breakpoint
150CREATE INDEX IF NOT EXISTS "prs_repo_state" ON "pull_requests" ("repository_id", "state");
151
152--> statement-breakpoint
153CREATE INDEX IF NOT EXISTS "prs_repo_number" ON "pull_requests" ("repository_id", "number");
154
155--> statement-breakpoint
156CREATE TABLE IF NOT EXISTS "pr_comments" (
157 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
158 "pull_request_id" uuid NOT NULL,
159 "author_id" uuid NOT NULL,
160 "body" text NOT NULL,
161 "is_ai_review" boolean DEFAULT false NOT NULL,
162 "file_path" text,
163 "line_number" integer,
164 "created_at" timestamp DEFAULT now() NOT NULL,
165 "updated_at" timestamp DEFAULT now() NOT NULL,
166 CONSTRAINT "pr_comments_pull_request_id_pull_requests_id_fk" FOREIGN KEY ("pull_request_id") REFERENCES "pull_requests" ("id") ON DELETE CASCADE,
167 CONSTRAINT "pr_comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users" ("id")
168);
169
170--> statement-breakpoint
171CREATE INDEX IF NOT EXISTS "pr_comments_pr" ON "pr_comments" ("pull_request_id");
172
173--> statement-breakpoint
174CREATE TABLE IF NOT EXISTS "activity_feed" (
175 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
176 "repository_id" uuid NOT NULL,
177 "user_id" uuid,
178 "action" text NOT NULL,
179 "target_type" text,
180 "target_id" text,
181 "metadata" text,
182 "created_at" timestamp DEFAULT now() NOT NULL,
183 CONSTRAINT "activity_feed_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE,
184 CONSTRAINT "activity_feed_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id")
185);
186
187--> statement-breakpoint
188CREATE INDEX IF NOT EXISTS "activity_repo" ON "activity_feed" ("repository_id");
189
190--> statement-breakpoint
191CREATE INDEX IF NOT EXISTS "activity_user" ON "activity_feed" ("user_id");
192
193--> statement-breakpoint
194CREATE TABLE IF NOT EXISTS "webhooks" (
195 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
196 "repository_id" uuid NOT NULL,
197 "url" text NOT NULL,
198 "secret" text,
199 "events" text DEFAULT 'push' NOT NULL,
200 "is_active" boolean DEFAULT true NOT NULL,
201 "last_delivered_at" timestamp,
202 "last_status" integer,
203 "created_at" timestamp DEFAULT now() NOT NULL,
204 CONSTRAINT "webhooks_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
205);
206
207--> statement-breakpoint
208CREATE INDEX IF NOT EXISTS "webhooks_repo" ON "webhooks" ("repository_id");
209
210--> statement-breakpoint
211CREATE TABLE IF NOT EXISTS "api_tokens" (
212 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
213 "user_id" uuid NOT NULL,
214 "name" text NOT NULL,
215 "token_hash" text NOT NULL,
216 "token_prefix" text NOT NULL,
217 "scopes" text DEFAULT 'repo' NOT NULL,
218 "last_used_at" timestamp,
219 "expires_at" timestamp,
220 "created_at" timestamp DEFAULT now() NOT NULL,
221 CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
222);
223
224--> statement-breakpoint
225CREATE TABLE IF NOT EXISTS "repo_topics" (
226 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
227 "repository_id" uuid NOT NULL,
228 "topic" text NOT NULL,
229 CONSTRAINT "repo_topics_repository_id_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories" ("id") ON DELETE CASCADE
230);
231
232--> statement-breakpoint
233CREATE UNIQUE INDEX IF NOT EXISTS "repo_topics_unique" ON "repo_topics" ("repository_id", "topic");
234
235--> statement-breakpoint
236CREATE INDEX IF NOT EXISTS "topics_name" ON "repo_topics" ("topic");
237
238--> statement-breakpoint
239CREATE TABLE IF NOT EXISTS "ssh_keys" (
240 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
241 "user_id" uuid NOT NULL,
242 "title" text NOT NULL,
243 "fingerprint" text NOT NULL,
244 "public_key" text NOT NULL,
245 "last_used_at" timestamp,
246 "created_at" timestamp DEFAULT now() NOT NULL,
247 CONSTRAINT "ssh_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
248);
Addedfly.toml+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1app = "gluecron"
2primary_region = "lhr"
3
4[build]
5 dockerfile = "Dockerfile"
6
7[env]
8 GIT_REPOS_PATH = "/app/repos"
9 PORT = "3000"
10 NODE_ENV = "production"
11
12[http_service]
13 internal_port = 3000
14 force_https = true
15 auto_stop_machines = "suspend"
16 auto_start_machines = true
17 min_machines_running = 1
18
19 [http_service.concurrency]
20 type = "connections"
21 hard_limit = 250
22 soft_limit = 200
23
24[deploy]
25 release_command = "bun run db:migrate"
26
27[[vm]]
28 size = "shared-cpu-1x"
29 memory = "512mb"
30
31[mounts]
32 source = "gluecron_repos"
33 destination = "/app/repos"
Modifiedpackage.json+1−0View fileUnifiedSplit
@@ -12,6 +12,7 @@
1212 "test": "bun test"
1313 },
1414 "dependencies": {
15 "@anthropic-ai/sdk": "^0.88.0",
1516 "@hono/node-server": "^1.13.0",
1617 "@neondatabase/serverless": "^0.10.0",
1718 "drizzle-orm": "^0.39.0",
Addedrailway.toml+10−0View fileUnifiedSplit
@@ -0,0 +1,10 @@
1[build]
2builder = "dockerfile"
3
4[deploy]
5releaseCommand = "bun run db:migrate"
6
7[env]
8GIT_REPOS_PATH = "/app/repos"
9PORT = "3000"
10NODE_ENV = "production"
Modifiedsrc/app.tsx+8−2View fileUnifiedSplit
@@ -1,6 +1,7 @@
11import { Hono } from "hono";
22import { logger } from "hono/logger";
33import { cors } from "hono/cors";
4import { compress } from "hono/compress";
45import { Layout } from "./views/layout";
56import gitRoutes from "./routes/git";
67import apiRoutes from "./routes/api";
@@ -20,8 +21,13 @@ import webRoutes from "./routes/web";
2021
2122const app = new Hono();
2223
23// Middleware
24app.use("*", logger());
24// Middleware — compression first (wraps all responses)
25app.use("*", compress());
26// Logger only on non-git routes to avoid overhead on clone/push
27app.use("*", async (c, next) => {
28 if (c.req.path.includes(".git/")) return next();
29 return logger()(c, next);
30});
2531app.use("/api/*", cors());
2632
2733// Git Smart HTTP protocol routes (must be before web routes)
Modifiedsrc/git/repository.ts+95−84View fileUnifiedSplit
@@ -1,6 +1,7 @@
11import { join } from "path";
22import { mkdir } from "fs/promises";
33import { config } from "../lib/config";
4import { gitCache, cached } from "../lib/cache";
45
56export interface GitCommit {
67 sha: string;
@@ -87,26 +88,30 @@ export async function listBranches(
8788 owner: string,
8889 name: string
8990): Promise<string[]> {
90 const path = repoPath(owner, name);
91 const { stdout, exitCode } = await exec(
92 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
93 { cwd: path }
94 );
95 if (exitCode !== 0) return [];
96 return stdout.trim().split("\n").filter(Boolean);
91 return cached(gitCache as any, `${owner}/${name}:branches`, async () => {
92 const path = repoPath(owner, name);
93 const { stdout, exitCode } = await exec(
94 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads/"],
95 { cwd: path }
96 );
97 if (exitCode !== 0) return [];
98 return stdout.trim().split("\n").filter(Boolean);
99 });
97100}
98101
99102export async function getDefaultBranch(
100103 owner: string,
101104 name: string
102105): Promise<string | null> {
103 const path = repoPath(owner, name);
104 const { stdout, exitCode } = await exec(
105 ["git", "symbolic-ref", "--short", "HEAD"],
106 { cwd: path }
107 );
108 if (exitCode !== 0) return null;
109 return stdout.trim() || null;
106 return cached(gitCache as any, `${owner}/${name}:defaultBranch`, async () => {
107 const path = repoPath(owner, name);
108 const { stdout, exitCode } = await exec(
109 ["git", "symbolic-ref", "--short", "HEAD"],
110 { cwd: path }
111 );
112 if (exitCode !== 0) return null;
113 return stdout.trim() || null;
114 });
110115}
111116
112117export async function resolveRef(
@@ -167,36 +172,38 @@ export async function listCommits(
167172 limit = 30,
168173 offset = 0
169174): Promise<GitCommit[]> {
170 const path = repoPath(owner, name);
171 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
172 const { stdout, exitCode } = await exec(
173 [
174 "git",
175 "log",
176 `--format=${format}`,
177 `--skip=${offset}`,
178 `-${limit}`,
179 ref,
180 ],
181 { cwd: path }
182 );
183 if (exitCode !== 0) return [];
184 return stdout
185 .trim()
186 .split("\n")
187 .filter(Boolean)
188 .map((line) => {
189 const [sha, message, author, authorEmail, date, parents] =
190 line.split("\0");
191 return {
192 sha,
193 message,
194 author,
195 authorEmail,
196 date,
197 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
198 };
199 });
175 return cached(gitCache as any, `${owner}/${name}:commits:${ref}:${limit}:${offset}`, async () => {
176 const path = repoPath(owner, name);
177 const format = "%H%x00%s%x00%an%x00%ae%x00%aI%x00%P";
178 const { stdout, exitCode } = await exec(
179 [
180 "git",
181 "log",
182 `--format=${format}`,
183 `--skip=${offset}`,
184 `-${limit}`,
185 ref,
186 ],
187 { cwd: path }
188 );
189 if (exitCode !== 0) return [];
190 return stdout
191 .trim()
192 .split("\n")
193 .filter(Boolean)
194 .map((line) => {
195 const [sha, message, author, authorEmail, date, parents] =
196 line.split("\0");
197 return {
198 sha,
199 message,
200 author,
201 authorEmail,
202 date,
203 parentShas: parents ? parents.split(" ").filter(Boolean) : [],
204 };
205 });
206 });
200207}
201208
202209export async function getTree(
@@ -205,39 +212,41 @@ export async function getTree(
205212 ref: string,
206213 treePath = ""
207214): Promise<GitTreeEntry[]> {
208 const path = repoPath(owner, name);
209 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
210 const { stdout, exitCode } = await exec(
211 ["git", "ls-tree", "-l", treeish],
212 { cwd: path }
213 );
214 if (exitCode !== 0) return [];
215 return stdout
216 .trim()
217 .split("\n")
218 .filter(Boolean)
219 .map((line) => {
220 // format: <mode> <type> <sha>\t<size>\t<name>
221 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
222 const match = line.match(
223 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
224 );
225 if (!match) return null;
226 return {
227 mode: match[1],
228 type: match[2] as "blob" | "tree" | "commit",
229 sha: match[3],
230 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
231 name: match[5],
232 };
233 })
234 .filter((e): e is GitTreeEntry => e !== null)
235 .sort((a, b) => {
236 // directories first, then files
237 if (a.type === "tree" && b.type !== "tree") return -1;
238 if (a.type !== "tree" && b.type === "tree") return 1;
239 return a.name.localeCompare(b.name);
240 });
215 return cached(gitCache as any, `${owner}/${name}:tree:${ref}:${treePath}`, async () => {
216 const path = repoPath(owner, name);
217 const treeish = treePath ? `${ref}:${treePath}` : `${ref}`;
218 const { stdout, exitCode } = await exec(
219 ["git", "ls-tree", "-l", treeish],
220 { cwd: path }
221 );
222 if (exitCode !== 0) return [];
223 return stdout
224 .trim()
225 .split("\n")
226 .filter(Boolean)
227 .map((line) => {
228 // format: <mode> <type> <sha>\t<size>\t<name>
229 // Actually: <mode> SP <type> SP <sha> SP <size> TAB <name>
230 const match = line.match(
231 /^(\d+)\s+(blob|tree|commit)\s+([0-9a-f]+)\s+(-|\d+)\t(.+)$/
232 );
233 if (!match) return null;
234 return {
235 mode: match[1],
236 type: match[2] as "blob" | "tree" | "commit",
237 sha: match[3],
238 size: match[4] === "-" ? undefined : parseInt(match[4], 10),
239 name: match[5],
240 };
241 })
242 .filter((e): e is GitTreeEntry => e !== null)
243 .sort((a, b) => {
244 // directories first, then files
245 if (a.type === "tree" && b.type !== "tree") return -1;
246 if (a.type !== "tree" && b.type === "tree") return 1;
247 return a.name.localeCompare(b.name);
248 });
249 });
241250}
242251
243252export async function getBlob(
@@ -431,11 +440,13 @@ export async function getReadme(
431440 name: string,
432441 ref: string
433442): Promise<string | null> {
434 const tree = await getTree(owner, name, ref);
435 const readme = tree.find((e) =>
436 /^readme(\.(md|txt|rst))?$/i.test(e.name)
437 );
438 if (!readme) return null;
439 const blob = await getBlob(owner, name, ref, readme.name);
440 return blob?.content || null;
443 return cached(gitCache as any, `${owner}/${name}:readme:${ref}`, async () => {
444 const tree = await getTree(owner, name, ref);
445 const readme = tree.find((e) =>
446 /^readme(\.(md|txt|rst))?$/i.test(e.name)
447 );
448 if (!readme) return null;
449 const blob = await getBlob(owner, name, ref, readme.name);
450 return blob?.content || null;
451 });
441452}
Modifiedsrc/hooks/post-receive.ts+18−30View fileUnifiedSplit
@@ -5,6 +5,7 @@
55 */
66
77import { config } from "../lib/config";
8import { runGateTestScan } from "../lib/gate";
89
910interface PushRef {
1011 oldSha: string;
@@ -19,10 +20,24 @@ export async function onPostReceive(
1920): Promise<void> {
2021 const promises: Promise<void>[] = [];
2122
22 // GateTest scan on every push
23 promises.push(triggerGateTest(owner, repo, refs));
23 // GateTest scan on every push (non-blocking, results stored for merge gating)
24 for (const ref of refs) {
25 if (!ref.newSha.startsWith("0000")) {
26 promises.push(
27 runGateTestScan(owner, repo, ref.refName, ref.newSha)
28 .then((result) => {
29 console.log(
30 `[gatetest] ${owner}/${repo} ${ref.refName}: ${result.passed ? "PASSED" : "FAILED"} — ${result.details}`
31 );
32 })
33 .catch((err) => {
34 console.error(`[gatetest] scan error for ${owner}/${repo}:`, err);
35 })
36 );
37 }
38 }
2439
25 // Crontech deploy on push to main
40 // Crontech deploy on push to main (only if GateTest passes)
2641 const mainPush = refs.find(
2742 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
2843 );
@@ -33,33 +48,6 @@ export async function onPostReceive(
3348 await Promise.allSettled(promises);
3449}
3550
36async function triggerGateTest(
37 owner: string,
38 repo: string,
39 refs: PushRef[]
40): Promise<void> {
41 try {
42 const response = await fetch(config.gatetestUrl, {
43 method: "POST",
44 headers: { "Content-Type": "application/json" },
45 body: JSON.stringify({
46 repository: `${owner}/${repo}`,
47 refs: refs.map((r) => ({
48 ref: r.refName,
49 before: r.oldSha,
50 after: r.newSha,
51 })),
52 source: "gluecron",
53 }),
54 });
55 console.log(
56 `[gatetest] scan triggered for ${owner}/${repo}: ${response.status}`
57 );
58 } catch (err) {
59 console.error(`[gatetest] failed to trigger scan:`, err);
60 }
61}
62
6351async function triggerCrontechDeploy(
6452 owner: string,
6553 repo: string,
Addedsrc/lib/ai-review.ts+125−0View fileUnifiedSplit
@@ -0,0 +1,125 @@
1/**
2 * AI-powered code review using Claude.
3 *
4 * Generates inline review comments on pull request diffs.
5 * Reviews are posted as PR comments with isAiReview=true.
6 */
7
8import Anthropic from "@anthropic-ai/sdk";
9import { config } from "./config";
10
11interface ReviewComment {
12 filePath: string;
13 lineNumber: number | null;
14 body: string;
15}
16
17interface ReviewResult {
18 summary: string;
19 comments: ReviewComment[];
20 approved: boolean;
21}
22
23let _client: Anthropic | null = null;
24
25function getClient(): Anthropic {
26 if (!_client) {
27 if (!config.anthropicApiKey) {
28 throw new Error("ANTHROPIC_API_KEY is not set");
29 }
30 _client = new Anthropic({ apiKey: config.anthropicApiKey });
31 }
32 return _client;
33}
34
35/**
36 * Run AI code review on a PR diff.
37 */
38export async function reviewDiff(
39 repoFullName: string,
40 prTitle: string,
41 prBody: string | null,
42 baseBranch: string,
43 headBranch: string,
44 diffText: string
45): Promise<ReviewResult> {
46 const client = getClient();
47
48 const message = await client.messages.create({
49 model: "claude-sonnet-4-20250514",
50 max_tokens: 4096,
51 messages: [
52 {
53 role: "user",
54 content: `You are reviewing a pull request on the repository "${repoFullName}".
55
56**PR Title:** ${prTitle}
57**PR Description:** ${prBody || "(none)"}
58**Base branch:** ${baseBranch}
59**Head branch:** ${headBranch}
60
61Review the following diff. Look for:
62- Bugs, logic errors, or potential runtime failures
63- Security vulnerabilities (injection, XSS, auth bypasses, secrets in code)
64- Performance issues (N+1 queries, unnecessary allocations, blocking I/O)
65- Missing error handling at system boundaries
66- Breaking changes or API contract violations
67
68Do NOT comment on style, formatting, naming, missing docs, or minor nitpicks. Only flag issues that could cause real problems.
69
70Respond in JSON format:
71{
72 "summary": "1-3 sentence overall assessment",
73 "approved": true/false,
74 "comments": [
75 {
76 "filePath": "path/to/file.ts",
77 "lineNumber": 42,
78 "body": "Explain the issue and suggest a fix"
79 }
80 ]
81}
82
83If the diff looks clean, return approved: true with an empty comments array.
84
85\`\`\`diff
86${diffText.slice(0, 100000)}
87\`\`\``,
88 },
89 ],
90 });
91
92 const text =
93 message.content[0].type === "text" ? message.content[0].text : "";
94
95 try {
96 // Extract JSON from response (may be wrapped in markdown code block)
97 const jsonMatch = text.match(/\{[\s\S]*\}/);
98 if (!jsonMatch) {
99 return {
100 summary: "AI review completed but could not parse structured output.",
101 comments: [],
102 approved: true,
103 };
104 }
105 const parsed = JSON.parse(jsonMatch[0]);
106 return {
107 summary: parsed.summary || "Review complete.",
108 comments: Array.isArray(parsed.comments) ? parsed.comments : [],
109 approved: parsed.approved !== false,
110 };
111 } catch {
112 return {
113 summary: text.slice(0, 500),
114 comments: [],
115 approved: true,
116 };
117 }
118}
119
120/**
121 * Check if AI review is available (API key configured).
122 */
123export function isAiReviewEnabled(): boolean {
124 return !!config.anthropicApiKey;
125}
Addedsrc/lib/cache.ts+123−0View fileUnifiedSplit
@@ -0,0 +1,123 @@
1/**
2 * In-memory LRU cache with TTL expiration.
3 *
4 * Used for caching git operations, session lookups,
5 * and other hot-path data to avoid redundant subprocess
6 * spawns and database roundtrips.
7 */
8
9interface CacheEntry<T> {
10 value: T;
11 expiresAt: number;
12}
13
14export class LRUCache<T> {
15 private cache = new Map<string, CacheEntry<T>>();
16 private readonly maxSize: number;
17 private readonly ttlMs: number;
18
19 constructor(maxSize: number, ttlMs: number) {
20 this.maxSize = maxSize;
21 this.ttlMs = ttlMs;
22 }
23
24 get(key: string): T | undefined {
25 const entry = this.cache.get(key);
26 if (!entry) return undefined;
27
28 if (Date.now() > entry.expiresAt) {
29 this.cache.delete(key);
30 return undefined;
31 }
32
33 // Move to end (most recently used)
34 this.cache.delete(key);
35 this.cache.set(key, entry);
36 return entry.value;
37 }
38
39 set(key: string, value: T): void {
40 // Delete first to update position
41 this.cache.delete(key);
42
43 // Evict oldest if at capacity
44 if (this.cache.size >= this.maxSize) {
45 const firstKey = this.cache.keys().next().value;
46 if (firstKey !== undefined) this.cache.delete(firstKey);
47 }
48
49 this.cache.set(key, {
50 value,
51 expiresAt: Date.now() + this.ttlMs,
52 });
53 }
54
55 invalidate(key: string): void {
56 this.cache.delete(key);
57 }
58
59 /**
60 * Invalidate all keys matching a prefix.
61 * Useful for clearing all cached data for a repo after a push.
62 */
63 invalidatePrefix(prefix: string): void {
64 for (const key of this.cache.keys()) {
65 if (key.startsWith(prefix)) {
66 this.cache.delete(key);
67 }
68 }
69 }
70
71 clear(): void {
72 this.cache.clear();
73 }
74
75 get size(): number {
76 return this.cache.size;
77 }
78}
79
80// --- Shared cache instances ---
81
82/** Git operation cache — trees, branches, commits, blobs (5 min TTL, 2000 entries) */
83export const gitCache = new LRUCache<unknown>(2000, 5 * 60 * 1000);
84
85/** Session cache — maps session tokens to user objects (2 min TTL, 500 entries) */
86export const sessionCache = new LRUCache<unknown>(500, 2 * 60 * 1000);
87
88/**
89 * Cache-through helper — returns cached value or runs the factory,
90 * caches the result, and returns it.
91 *
92 * Does NOT cache empty arrays or null — avoids stale empty results
93 * when a repo is freshly created or still receiving its first push.
94 */
95export async function cached<T>(
96 cache: LRUCache<T>,
97 key: string,
98 factory: () => Promise<T>
99): Promise<T> {
100 const existing = cache.get(key);
101 if (existing !== undefined) return existing;
102
103 const value = await factory();
104
105 // Only cache non-empty results
106 if (value !== null && value !== undefined) {
107 if (Array.isArray(value) && value.length === 0) {
108 // Don't cache empty arrays — repo may just be initializing
109 } else {
110 cache.set(key, value);
111 }
112 }
113
114 return value;
115}
116
117/**
118 * Invalidate all cached data for a repository.
119 * Call this after pushes, merges, or any repo-mutating operation.
120 */
121export function invalidateRepoCache(owner: string, repo: string): void {
122 gitCache.invalidatePrefix(`${owner}/${repo}:`);
123}
Modifiedsrc/lib/config.ts+6−0View fileUnifiedSplit
@@ -13,10 +13,16 @@ export const config = {
1313 get gatetestUrl() {
1414 return process.env.GATETEST_URL || "https://gatetest.ai/api/scan/run";
1515 },
16 get gatetestApiKey() {
17 return process.env.GATETEST_API_KEY || "";
18 },
1619 get crontechDeployUrl() {
1720 return (
1821 process.env.CRONTECH_DEPLOY_URL ||
1922 "https://crontech.ai/api/trpc/tenant.deploy"
2023 );
2124 },
25 get anthropicApiKey() {
26 return process.env.ANTHROPIC_API_KEY || "";
27 },
2228};
Addedsrc/lib/gate.ts+184−0View fileUnifiedSplit
@@ -0,0 +1,184 @@
1/**
2 * Green gate enforcement.
3 *
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)
7 *
8 * Nothing ships unless everything is green.
9 */
10
11import { config } from "./config";
12
13export interface GateCheckResult {
14 name: string;
15 passed: boolean;
16 details: string;
17}
18
19export interface GateResult {
20 allPassed: boolean;
21 checks: GateCheckResult[];
22}
23
24/**
25 * Run GateTest scan on a repository at a specific ref.
26 * Returns pass/fail with details.
27 */
28export async function runGateTestScan(
29 owner: string,
30 repo: string,
31 ref: string,
32 headSha: string
33): Promise<GateCheckResult> {
34 if (!config.gatetestUrl) {
35 return { name: "GateTest", passed: true, details: "GateTest URL not configured — skipped" };
36 }
37
38 try {
39 const headers: Record<string, string> = {
40 "Content-Type": "application/json",
41 };
42 if (config.gatetestApiKey) {
43 headers["Authorization"] = `Bearer ${config.gatetestApiKey}`;
44 }
45
46 const response = await fetch(config.gatetestUrl, {
47 method: "POST",
48 headers,
49 body: JSON.stringify({
50 repository: `${owner}/${repo}`,
51 ref,
52 sha: headSha,
53 source: "gluecron",
54 mode: "blocking", // Wait for results instead of fire-and-forget
55 }),
56 });
57
58 if (!response.ok) {
59 const body = await response.text().catch(() => "");
60 return {
61 name: "GateTest",
62 passed: false,
63 details: `GateTest returned ${response.status}: ${body.slice(0, 200)}`,
64 };
65 }
66
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 };
78 } catch (err) {
79 console.error("[gate] GateTest scan error:", err);
80 return {
81 name: "GateTest",
82 passed: false,
83 details: `GateTest scan failed: ${err instanceof Error ? err.message : "Unknown error"}`,
84 };
85 }
86}
87
88/**
89 * Check for merge conflicts between branches.
90 */
91export async function checkMergeability(
92 owner: string,
93 repo: string,
94 baseBranch: string,
95 headBranch: string
96): Promise<GateCheckResult> {
97 const { getRepoPath } = await import("../git/repository");
98 const repoDir = getRepoPath(owner, repo);
99
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)
108 const ffCheck = Bun.spawn(
109 ["git", "merge-base", "--is-ancestor", baseBranch, headBranch],
110 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
111 );
112 const ffExit = await ffCheck.exited;
113
114 if (ffExit === 0) {
115 return { name: "Merge check", passed: true, details: "Fast-forward merge possible" };
116 }
117
118 // Check if there would be conflicts
119 const mergeBase = Bun.spawn(
120 ["git", "merge-base", baseBranch, headBranch],
121 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
122 );
123 const baseOut = await new Response(mergeBase.stdout).text();
124 const baseExit = await mergeBase.exited;
125
126 if (baseExit !== 0) {
127 return { name: "Merge check", passed: false, details: "Branches have no common ancestor" };
128 }
129
130 // Use merge-tree (three-way) to detect conflicts without touching working tree
131 const mergeTree = Bun.spawn(
132 ["git", "merge-tree", baseOut.trim(), baseBranch, headBranch],
133 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
134 );
135 const treeOut = await new Response(mergeTree.stdout).text();
136 await mergeTree.exited;
137
138 const hasConflicts = treeOut.includes("<<<<<<<");
139
140 return {
141 name: "Merge check",
142 passed: !hasConflicts,
143 details: hasConflicts
144 ? "Merge conflicts detected — auto-resolution will be attempted"
145 : "Clean merge possible",
146 };
147}
148
149/**
150 * Run all gate checks for a PR merge.
151 */
152export async function runAllGateChecks(
153 owner: string,
154 repo: string,
155 baseBranch: string,
156 headBranch: string,
157 headSha: string,
158 aiReviewApproved: boolean
159): Promise<GateResult> {
160 const checks: GateCheckResult[] = [];
161
162 // Run GateTest and mergeability check in parallel
163 const [gateTestResult, mergeResult] = await Promise.all([
164 runGateTestScan(owner, repo, `refs/heads/${headBranch}`, headSha),
165 checkMergeability(owner, repo, baseBranch, headBranch),
166 ]);
167
168 checks.push(gateTestResult);
169 checks.push(mergeResult);
170
171 // AI review check
172 checks.push({
173 name: "AI Review",
174 passed: aiReviewApproved,
175 details: aiReviewApproved
176 ? "AI review approved"
177 : "AI review found blocking issues — resolve before merging",
178 });
179
180 return {
181 allPassed: checks.every((c) => c.passed),
182 checks,
183 };
184}
Addedsrc/lib/merge-resolver.ts+212−0View fileUnifiedSplit
@@ -0,0 +1,212 @@
1/**
2 * Automated merge conflict resolution using Claude.
3 *
4 * When a merge has conflicts, this module:
5 * 1. Detects conflicting files
6 * 2. Sends each conflict to Claude for resolution
7 * 3. Applies the resolved content and completes the merge
8 */
9
10import Anthropic from "@anthropic-ai/sdk";
11import { config } from "./config";
12import { getRepoPath } from "../git/repository";
13
14interface ConflictFile {
15 path: string;
16 content: string;
17}
18
19interface ResolvedFile {
20 path: string;
21 content: string;
22}
23
24interface MergeResult {
25 success: boolean;
26 resolvedFiles: string[];
27 error?: string;
28 commitSha?: string;
29}
30
31let _client: Anthropic | null = null;
32
33function getClient(): Anthropic {
34 if (!_client) {
35 if (!config.anthropicApiKey) {
36 throw new Error("ANTHROPIC_API_KEY is not set");
37 }
38 _client = new Anthropic({ apiKey: config.anthropicApiKey });
39 }
40 return _client;
41}
42
43async function exec(
44 cmd: string[],
45 opts?: { cwd?: string; env?: Record<string, string> }
46): Promise<{ stdout: string; stderr: string; exitCode: number }> {
47 const proc = Bun.spawn(cmd, {
48 cwd: opts?.cwd,
49 env: { ...process.env, ...opts?.env },
50 stdout: "pipe",
51 stderr: "pipe",
52 });
53 const [stdout, stderr] = await Promise.all([
54 new Response(proc.stdout).text(),
55 new Response(proc.stderr).text(),
56 ]);
57 const exitCode = await proc.exited;
58 return { stdout, stderr, exitCode };
59}
60
61/**
62 * Attempt to merge with automatic conflict resolution via Claude.
63 *
64 * This works in a temporary worktree to avoid disturbing the bare repo state.
65 */
66export async function mergeWithAutoResolve(
67 owner: string,
68 repo: string,
69 baseBranch: string,
70 headBranch: string,
71 mergeMessage: string
72): Promise<MergeResult> {
73 const repoDir = getRepoPath(owner, repo);
74 const worktree = `${repoDir}/_merge_worktree_${Date.now()}`;
75
76 try {
77 // Create a temporary worktree on the base branch
78 const addWt = await exec(
79 ["git", "worktree", "add", worktree, baseBranch],
80 { cwd: repoDir }
81 );
82 if (addWt.exitCode !== 0) {
83 return { success: false, resolvedFiles: [], error: `Failed to create worktree: ${addWt.stderr}` };
84 }
85
86 // Attempt the merge
87 const merge = await exec(
88 ["git", "merge", "--no-commit", "--no-ff", `origin/${headBranch}`],
89 { cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
90 );
91
92 // If merge succeeded clean (no conflicts), commit it
93 if (merge.exitCode === 0) {
94 const commit = await exec(
95 ["git", "commit", "-m", mergeMessage],
96 { cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
97 );
98
99 // Get the merge commit SHA
100 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
101
102 // Update the bare repo's base branch ref
103 await exec(
104 ["git", "update-ref", `refs/heads/${baseBranch}`, sha.trim()],
105 { cwd: repoDir }
106 );
107
108 return { success: true, resolvedFiles: [], commitSha: sha.trim() };
109 }
110
111 // There are conflicts — get the list of conflicting files
112 const { stdout: statusOut } = await exec(["git", "diff", "--name-only", "--diff-filter=U"], { cwd: worktree });
113 const conflictPaths = statusOut.trim().split("\n").filter(Boolean);
114
115 if (conflictPaths.length === 0) {
116 return { success: false, resolvedFiles: [], error: "Merge failed but no conflicts detected" };
117 }
118
119 // Read each conflicting file and resolve with Claude
120 const resolvedFiles: string[] = [];
121 for (const filePath of conflictPaths) {
122 const { stdout: conflictContent } = await exec(["cat", filePath], { cwd: worktree });
123 const resolved = await resolveConflict(filePath, conflictContent);
124
125 if (resolved) {
126 // Write resolved content
127 await Bun.write(`${worktree}/${filePath}`, resolved.content);
128 await exec(["git", "add", filePath], { cwd: worktree });
129 resolvedFiles.push(filePath);
130 } else {
131 // Could not resolve this file — abort
132 await exec(["git", "merge", "--abort"], { cwd: worktree });
133 return {
134 success: false,
135 resolvedFiles: [],
136 error: `Could not auto-resolve conflict in ${filePath}`,
137 };
138 }
139 }
140
141 // All conflicts resolved — commit
142 const commit = await exec(
143 ["git", "commit", "-m", `${mergeMessage}\n\nAuto-resolved conflicts in: ${resolvedFiles.join(", ")}`],
144 { cwd: worktree, env: { GIT_AUTHOR_NAME: "GlueCron AI", GIT_AUTHOR_EMAIL: "ai@gluecron.com", GIT_COMMITTER_NAME: "GlueCron AI", GIT_COMMITTER_EMAIL: "ai@gluecron.com" } }
145 );
146
147 if (commit.exitCode !== 0) {
148 return { success: false, resolvedFiles, error: `Commit failed: ${commit.stderr}` };
149 }
150
151 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: worktree });
152
153 // Update the bare repo ref
154 await exec(
155 ["git", "update-ref", `refs/heads/${baseBranch}`, sha.trim()],
156 { cwd: repoDir }
157 );
158
159 return { success: true, resolvedFiles, commitSha: sha.trim() };
160 } finally {
161 // Clean up the worktree
162 await exec(["git", "worktree", "remove", "--force", worktree], { cwd: repoDir }).catch(() => {});
163 }
164}
165
166/**
167 * Use Claude to resolve a single file's merge conflicts.
168 */
169async function resolveConflict(
170 filePath: string,
171 conflictContent: string
172): Promise<ResolvedFile | null> {
173 const client = getClient();
174
175 try {
176 const message = await client.messages.create({
177 model: "claude-sonnet-4-20250514",
178 max_tokens: 8192,
179 messages: [
180 {
181 role: "user",
182 content: `You are resolving a git merge conflict in the file "${filePath}".
183
184The file contains conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch). Your job is to produce the correctly merged version of the file.
185
186Rules:
187- Keep BOTH sides' changes when they don't contradict
188- When changes truly conflict, choose the version that preserves correctness and doesn't break functionality
189- Remove ALL conflict markers (<<<<<<< HEAD, =======, >>>>>>>)
190- The output must be valid, working code
191- Output ONLY the resolved file content, no explanation, no code fences
192
193File content with conflicts:
194${conflictContent}`,
195 },
196 ],
197 });
198
199 const text = message.content[0].type === "text" ? message.content[0].text : "";
200
201 // Verify no conflict markers remain
202 if (text.includes("<<<<<<<") || text.includes(">>>>>>>")) {
203 console.error(`[merge-resolver] Claude left conflict markers in ${filePath}`);
204 return null;
205 }
206
207 return { path: filePath, content: text };
208 } catch (err) {
209 console.error(`[merge-resolver] Failed to resolve ${filePath}:`, err);
210 return null;
211 }
212}
Modifiedsrc/middleware/auth.ts+13−0View fileUnifiedSplit
@@ -1,5 +1,6 @@
11/**
22 * Auth middleware — reads session cookie, injects user into context.
3 * Uses in-memory session cache to avoid DB roundtrip on every request.
34 */
45
56import { createMiddleware } from "hono/factory";
@@ -8,6 +9,7 @@ import { eq, gt } from "drizzle-orm";
89import { db } from "../db";
910import { sessions, users } from "../db/schema";
1011import type { User } from "../db/schema";
12import { sessionCache } from "../lib/cache";
1113
1214export type AuthEnv = {
1315 Variables: {
@@ -18,6 +20,7 @@ export type AuthEnv = {
1820/**
1921 * Soft auth — sets c.get("user") to the current user or null.
2022 * Does NOT block unauthenticated requests.
23 * Caches session->user mapping for 2 minutes to avoid DB roundtrip per request.
2124 */
2225export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
2326 const token = getCookie(c, "session");
@@ -26,6 +29,13 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
2629 return next();
2730 }
2831
32 // Check session cache first
33 const cachedUser = sessionCache.get(token) as User | null | undefined;
34 if (cachedUser !== undefined) {
35 c.set("user", cachedUser);
36 return next();
37 }
38
2939 try {
3040 const [session] = await db
3141 .select()
@@ -34,6 +44,7 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
3444 .limit(1);
3545
3646 if (!session || new Date(session.expiresAt) < new Date()) {
47 sessionCache.set(token, null as any);
3748 c.set("user", null);
3849 return next();
3950 }
@@ -44,6 +55,8 @@ export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
4455 .where(eq(users.id, session.userId))
4556 .limit(1);
4657
58 // Cache the result (user or null)
59 sessionCache.set(token, (user || null) as any);
4760 c.set("user", user || null);
4861 } catch {
4962 c.set("user", null);
Modifiedsrc/routes/git.ts+4−0View fileUnifiedSplit
@@ -8,6 +8,7 @@ import { Hono } from "hono";
88import { getInfoRefs, serviceRpc } from "../git/protocol";
99import { repoExists } from "../git/repository";
1010import { onPostReceive } from "../hooks/post-receive";
11import { invalidateRepoCache } from "../lib/cache";
1112
1213const git = new Hono();
1314
@@ -64,6 +65,9 @@ git.post("/:owner/:repo.git/git-receive-pack", async (c) => {
6465 bodyBuffer
6566 );
6667
68 // Invalidate cached git data for this repo immediately
69 invalidateRepoCache(owner, repo);
70
6771 // Fire post-receive hooks asynchronously (don't block response)
6872 // We parse updated refs from the pkt-line protocol in the request
6973 const refs = parseReceivePackRefs(new Uint8Array(bodyBuffer));
Modifiedsrc/routes/pulls.tsx+236−30View fileUnifiedSplit
@@ -19,9 +19,13 @@ import type { AuthEnv } from "../middleware/auth";
1919import {
2020 listBranches,
2121 getRepoPath,
22 resolveRef,
2223} from "../git/repository";
2324import type { GitDiffFile } from "../git/repository";
2425import { html } from "hono/html";
26import { reviewDiff, isAiReviewEnabled } from "../lib/ai-review";
27import { mergeWithAutoResolve } from "../lib/merge-resolver";
28import { runAllGateChecks, type GateCheckResult } from "../lib/gate";
2529
2630const pulls = new Hono<AuthEnv>();
2731
@@ -291,6 +295,13 @@ pulls.post(
291295 })
292296 .returning();
293297
298 // Trigger AI code review asynchronously
299 if (isAiReviewEnabled()) {
300 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
301 (err) => console.error("[ai-review] Failed:", err)
302 );
303 }
304
294305 return c.redirect(`/${ownerName}/${repoName}/pulls/${pr.number}`);
295306 }
296307);
@@ -338,6 +349,24 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
338349 user &&
339350 (user.id === resolved.owner.id || user.id === pr.authorId);
340351
352 const error = c.req.query("error");
353
354 // Get gate check status for open PRs
355 let gateChecks: GateCheckResult[] = [];
356 if (pr.state === "open") {
357 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
358 if (headSha) {
359 const aiComments = comments.filter(({ comment }) => comment.isAiReview);
360 const aiApproved = aiComments.length === 0 || aiComments.some(
361 ({ comment }) => comment.body.includes("**Approved**")
362 );
363 const gateResult = await runAllGateChecks(
364 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
365 );
366 gateChecks = gateResult.checks;
367 }
368 }
369
341370 // Get diff for "Files changed" tab
342371 let diffRaw = "";
343372 let diffFiles: GitDiffFile[] = [];
@@ -463,6 +492,34 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
463492 </div>
464493 ))}
465494
495 {error && (
496 <div class="auth-error" style="margin-top: 16px; padding: 12px; background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); color: var(--red)">
497 {decodeURIComponent(error)}
498 </div>
499 )}
500
501 {pr.state === "open" && gateChecks.length > 0 && (
502 <div style="margin-top: 20px; padding: 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius)">
503 <h3 style="margin: 0 0 12px; font-size: 14px">Gate Checks</h3>
504 {gateChecks.map((check) => (
505 <div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border)">
506 <span style={`font-size: 16px; color: ${check.passed ? "var(--green)" : "var(--red)"}`}>
507 {check.passed ? "\u2713" : "\u2717"}
508 </span>
509 <strong style="font-size: 13px">{check.name}</strong>
510 <span style="font-size: 12px; color: var(--text-muted); margin-left: auto">{check.details}</span>
511 </div>
512 ))}
513 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
514 {gateChecks.every((c) => c.passed)
515 ? "All checks passed — ready to merge"
516 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
517 ? "Conflicts detected — GlueCron AI will attempt auto-resolution on merge"
518 : "Some checks failed — resolve issues before merging"}
519 </div>
520 </div>
521 )}
522
466523 {user && pr.state === "open" && (
467524 <div style="margin-top: 20px">
468525 <form
@@ -488,9 +545,13 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
488545 type="submit"
489546 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
490547 class="btn"
491 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
548 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
492549 >
493 Merge pull request
550 {gateChecks.every((c) => c.passed)
551 ? "Merge pull request"
552 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
553 ? "Merge with auto-resolve"
554 : "Merge pull request"}
494555 </button>
495556 <button
496557 type="submit"
@@ -554,7 +615,7 @@ pulls.post(
554615 }
555616);
556617
557// Merge PR
618// Merge PR — with green gate enforcement and auto conflict resolution
558619pulls.post(
559620 "/:owner/:repo/pulls/:number/merge",
560621 softAuth,
@@ -582,40 +643,101 @@ pulls.post(
582643 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
583644 }
584645
585 // Perform git merge
586 const repoDir = getRepoPath(ownerName, repoName);
587 const mergeProc = Bun.spawn(
588 [
589 "git",
590 "merge-base",
591 "--is-ancestor",
592 pr.baseBranch,
593 pr.headBranch,
594 ],
595 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
646 // Resolve head SHA
647 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
648 if (!headSha) {
649 return c.redirect(
650 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Head branch not found")}`
651 );
652 }
653
654 // Check if AI review approved this PR
655 const aiComments = await db
656 .select()
657 .from(prComments)
658 .where(
659 and(
660 eq(prComments.pullRequestId, pr.id),
661 eq(prComments.isAiReview, true)
662 )
663 );
664 const aiApproved = aiComments.length === 0 || aiComments.some(
665 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
596666 );
597 await mergeProc.exited;
598
599 // Use git update-ref for fast-forward or create merge commit
600 const ffProc = Bun.spawn(
601 [
602 "git",
603 "update-ref",
604 `refs/heads/${pr.baseBranch}`,
605 `refs/heads/${pr.headBranch}`,
606 ],
607 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
667
668 // Run all green gate checks (GateTest + mergeability + AI review)
669 const gateResult = await runAllGateChecks(
670 ownerName,
671 repoName,
672 pr.baseBranch,
673 pr.headBranch,
674 headSha,
675 aiApproved
608676 );
609 const ffExit = await ffProc.exited;
610677
611 if (ffExit !== 0) {
612 // Fallback: try creating a merge commit via a temporary checkout
613 // For now, just report the error
678 // If GateTest or AI review failed (hard blocks), reject the merge
679 const hardFailures = gateResult.checks.filter(
680 (check) => !check.passed && check.name !== "Merge check"
681 );
682 if (hardFailures.length > 0) {
683 const errorMsg = hardFailures
684 .map((f) => `${f.name}: ${f.details}`)
685 .join("; ");
614686 return c.redirect(
615 `/${ownerName}/${repoName}/pulls/${prNum}?error=merge_conflict`
687 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(errorMsg)}`
616688 );
617689 }
618690
691 // Attempt the merge — with auto conflict resolution if needed
692 const repoDir = getRepoPath(ownerName, repoName);
693 const mergeCheck = gateResult.checks.find((c) => c.name === "Merge check");
694 const hasConflicts = mergeCheck && !mergeCheck.passed;
695
696 if (hasConflicts && isAiReviewEnabled()) {
697 // Use Claude to auto-resolve conflicts
698 const mergeResult = await mergeWithAutoResolve(
699 ownerName,
700 repoName,
701 pr.baseBranch,
702 pr.headBranch,
703 `Merge pull request #${pr.number}: ${pr.title}`
704 );
705
706 if (!mergeResult.success) {
707 return c.redirect(
708 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(mergeResult.error || "Auto-merge failed")}`
709 );
710 }
711
712 // Post a comment about the auto-resolution
713 if (mergeResult.resolvedFiles.length > 0) {
714 await db.insert(prComments).values({
715 pullRequestId: pr.id,
716 authorId: user.id,
717 body: `**Auto-resolved merge conflicts** in:\n${mergeResult.resolvedFiles.map((f) => `- \`${f}\``).join("\n")}\n\nConflicts were automatically resolved by GlueCron AI.`,
718 isAiReview: true,
719 });
720 }
721 } else {
722 // Standard merge — fast-forward or clean merge
723 const ffProc = Bun.spawn(
724 [
725 "git",
726 "update-ref",
727 `refs/heads/${pr.baseBranch}`,
728 `refs/heads/${pr.headBranch}`,
729 ],
730 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
731 );
732 const ffExit = await ffProc.exited;
733
734 if (ffExit !== 0) {
735 return c.redirect(
736 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Merge failed — unable to update branch ref")}`
737 );
738 }
739 }
740
619741 await db
620742 .update(pullRequests)
621743 .set({
@@ -660,6 +782,90 @@ pulls.post(
660782 }
661783);
662784
785/**
786 * Trigger AI code review asynchronously after PR creation.
787 * Runs the diff through Claude and posts review comments.
788 */
789async function triggerAiReview(
790 ownerName: string,
791 repoName: string,
792 prId: string,
793 title: string,
794 body: string | null,
795 baseBranch: string,
796 headBranch: string
797): Promise<void> {
798 const repoDir = getRepoPath(ownerName, repoName);
799
800 // Get the diff between branches
801 const proc = Bun.spawn(
802 ["git", "diff", `${baseBranch}...${headBranch}`],
803 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
804 );
805 const diffText = await new Response(proc.stdout).text();
806 await proc.exited;
807
808 if (!diffText.trim()) return;
809
810 const result = await reviewDiff(
811 `${ownerName}/${repoName}`,
812 title,
813 body,
814 baseBranch,
815 headBranch,
816 diffText
817 );
818
819 // We need a system user for AI reviews — use the PR author for now
820 // Get the PR to find the author
821 const [pr] = await db
822 .select()
823 .from(pullRequests)
824 .where(eq(pullRequests.id, prId))
825 .limit(1);
826
827 if (!pr) return;
828
829 // Post summary comment
830 const statusEmoji = result.approved ? "**Approved**" : "**Changes Requested**";
831 let commentBody = `## AI Code Review ${statusEmoji}\n\n${result.summary}`;
832
833 if (result.comments.length > 0) {
834 commentBody += "\n\n### Issues Found\n";
835 for (const comment of result.comments) {
836 const location = comment.filePath
837 ? `\`${comment.filePath}${comment.lineNumber ? `:${comment.lineNumber}` : ""}\``
838 : "";
839 commentBody += `\n---\n${location}\n\n${comment.body}\n`;
840 }
841 }
842
843 await db.insert(prComments).values({
844 pullRequestId: prId,
845 authorId: pr.authorId,
846 body: commentBody,
847 isAiReview: true,
848 });
849
850 // Post individual file-level comments
851 for (const comment of result.comments) {
852 if (comment.filePath) {
853 await db.insert(prComments).values({
854 pullRequestId: prId,
855 authorId: pr.authorId,
856 body: comment.body,
857 isAiReview: true,
858 filePath: comment.filePath,
859 lineNumber: comment.lineNumber,
860 });
861 }
862 }
863
864 console.log(
865 `[ai-review] Review posted for PR ${prId}: ${result.approved ? "approved" : "changes requested"}, ${result.comments.length} comments`
866 );
867}
868
663869function formatRelative(date: Date | string): string {
664870 const d = typeof date === "string" ? new Date(date) : date;
665871 const now = new Date();
Modifiedsrc/routes/web.tsx+41−33View fileUnifiedSplit
@@ -342,32 +342,34 @@ web.get("/:owner/:repo", async (c) => {
342342 );
343343 }
344344
345 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
346 const branches = await listBranches(owner, repo);
347 const tree = await getTree(owner, repo, defaultBranch);
348
349 // Get star info if user logged in
350 let starCount = 0;
351 let starred = false;
352 try {
353 const [ownerUser] = await db
354 .select()
355 .from(users)
356 .where(eq(users.username, owner))
357 .limit(1);
358 if (ownerUser) {
359 const [repoRow] = await db
360 .select()
361 .from(repositories)
362 .where(
363 and(
364 eq(repositories.ownerId, ownerUser.id),
365 eq(repositories.name, repo)
345 // Parallelize all independent operations
346 const [defaultBranch, branches] = await Promise.all([
347 getDefaultBranch(owner, repo).then((b) => b || "main"),
348 listBranches(owner, repo),
349 ]);
350 const [tree, starInfo] = await Promise.all([
351 getTree(owner, repo, defaultBranch),
352 // Star info fetched in parallel with tree
353 (async () => {
354 try {
355 const [ownerUser] = await db
356 .select()
357 .from(users)
358 .where(eq(users.username, owner))
359 .limit(1);
360 if (!ownerUser) return { starCount: 0, starred: false };
361 const [repoRow] = await db
362 .select()
363 .from(repositories)
364 .where(
365 and(
366 eq(repositories.ownerId, ownerUser.id),
367 eq(repositories.name, repo)
368 )
366369 )
367 )
368 .limit(1);
369 if (repoRow) {
370 starCount = repoRow.starCount;
370 .limit(1);
371 if (!repoRow) return { starCount: 0, starred: false };
372 let starred = false;
371373 if (user) {
372374 const [star] = await db
373375 .select()
@@ -381,11 +383,13 @@ web.get("/:owner/:repo", async (c) => {
381383 .limit(1);
382384 starred = !!star;
383385 }
386 return { starCount: repoRow.starCount, starred };
387 } catch {
388 return { starCount: 0, starred: false };
384389 }
385 }
386 } catch {
387 // DB not available
388 }
390 })(),
391 ]);
392 const { starCount, starred } = starInfo;
389393
390394 if (tree.length === 0) {
391395 return c.html(
@@ -641,7 +645,12 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
641645 const { owner, repo, sha } = c.req.param();
642646 const user = c.get("user");
643647
644 const commit = await getCommit(owner, repo, sha);
648 // Fetch commit, full message, and diff in parallel
649 const [commit, fullMessage, diffResult] = await Promise.all([
650 getCommit(owner, repo, sha),
651 getCommitFullMessage(owner, repo, sha),
652 getDiff(owner, repo, sha),
653 ]);
645654 if (!commit) {
646655 return c.html(
647656 <Layout title="Not Found" user={user}>
@@ -653,8 +662,7 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
653662 );
654663 }
655664
656 const fullMessage = await getCommitFullMessage(owner, repo, sha);
657 const { files, raw } = await getDiff(owner, repo, sha);
665 const { files, raw } = diffResult;
658666
659667 return c.html(
660668 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
@@ -734,7 +742,7 @@ web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
734742 headers: {
735743 "Content-Type": "application/octet-stream",
736744 "Content-Disposition": `attachment; filename="${fileName}"`,
737 "Cache-Control": "no-cache",
745 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
738746 },
739747 });
740748});
741749