Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

625 lines · 34.9 KBRaw History
1import { ACTIONS_APP } from "./required.js";
2import {
3 CODE_TYPES,
4 EMPTY_TREE_OID,
5 hashObject,
6 isAncestor,
7 isOid,
8 isTreeMode,
9 mergeBase,
10 mergeTrees,
11 parseTag,
12 readCommit,
13 readTree,
14 resolvePath,
15 serializeCommit,
16 serializeTag,
17 serializeTree,
18 type ObjectType,
19 type Signature,
20 type TreeEntry,
21 utf8,
22} from "@diffinery/git-core";
23import type { Env } from "../env.js";
24import { scanForSecrets, type SecretHit } from "../git/secret-scan.js";
25import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js";
26import { R2Sink } from "../lib/r2-sink.js";
27import { unsafeTreeName, unsafeTreePath } from "../lib/tree-names.js";
28import { HttpError, now } from "../lib/util.js";
29import { archiveKey } from "../lib/storage-keys.js";
30import type { Check } from "./collab.js";
31import { toInfo, type CommitInfo, type RepoDO } from "./RepoDO.js";
32import { atLeast, type Caller } from "./types.js";
33
34export type SigInput = { name?: string; email?: string; date?: string };
35export type TreeInput = { path?: string; mode?: string; type?: string; sha?: string | null; content?: string };
36export type GitTreeEntry = { path: string; mode: string; type: "blob" | "tree" | "commit"; sha: string; size?: number };
37
38const MODES = new Set(["100644", "100755", "040000", "160000", "120000"]);
39/** Uncompressed file content an archive may hold; bigger repositories should be cloned. */
40const MAX_ARCHIVE_BYTES = 24 * 1024 * 1024;
41/** Tree entries a walk may visit (repeated subtrees count every time), against tree bombs. */
42const MAX_TREE_ENTRIES = 100_000;
43const MAX_TREE_DEPTH = 128;
44/** Archive builds per repository per hour (cached archives don't count). */
45const ARCHIVE_BUILDS_PER_HOUR = 20;
46
47/** Language by file extension, for GET /languages (programming and markup languages, like linguist). */
48const LANGUAGES: Record<string, string> = {
49 ts: "TypeScript", tsx: "TypeScript", mts: "TypeScript", cts: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
50 py: "Python", rb: "Ruby", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", kts: "Kotlin", scala: "Scala", swift: "Swift", m: "Objective-C",
51 c: "C", h: "C", cc: "C++", cpp: "C++", cxx: "C++", hpp: "C++", hh: "C++", cs: "C#", fs: "F#", php: "PHP", pl: "Perl", lua: "Lua", r: "R",
52 dart: "Dart", ex: "Elixir", exs: "Elixir", erl: "Erlang", hs: "Haskell", clj: "Clojure", elm: "Elm", ml: "OCaml", zig: "Zig", nim: "Nim",
53 sh: "Shell", bash: "Shell", zsh: "Shell", ps1: "PowerShell", sql: "PLpgSQL", html: "HTML", htm: "HTML", css: "CSS", scss: "SCSS", sass: "Sass",
54 less: "Less", vue: "Vue", svelte: "Svelte", astro: "Astro", tf: "HCL", hcl: "HCL", nix: "Nix", sol: "Solidity", vb: "Visual Basic .NET",
55 groovy: "Groovy", jl: "Julia", cr: "Crystal", v: "V", d: "D", asm: "Assembly", s: "Assembly", mk: "Makefile", cmake: "CMake", proto: "Protocol Buffer",
56};
57const LANGUAGE_FILES: Record<string, string> = { Dockerfile: "Dockerfile", Makefile: "Makefile", "CMakeLists.txt": "CMake", Rakefile: "Ruby", Gemfile: "Ruby" };
58const VENDORED = /(^|\/)(node_modules|vendor|third_party|dist|build|\.yarn)\//;
59
60function parseSig(input: SigInput | undefined, fallback: Signature, field: string): Signature {
61 if (!input) return fallback;
62 if (!input.name || !input.email) throw new HttpError(422, `${field} needs a name and an email`, { errors: [{ resource: "Commit", code: "missing_field", field }] });
63 let timestamp = fallback.timestamp;
64 let timezone = "+0000";
65 if (input.date) {
66 const t = Date.parse(input.date);
67 if (Number.isNaN(t)) throw new HttpError(422, `${field}.date must be an ISO 8601 date`);
68 timestamp = Math.floor(t / 1000);
69 const m = /([+-])(\d{2}):?(\d{2})$/.exec(input.date);
70 if (m) timezone = `${m[1]}${m[2]}${m[3]}`;
71 }
72 return { name: input.name.replace(/[<>\n]/g, ""), email: input.email.replace(/[<>\n]/g, ""), timestamp, timezone };
73}
74
75const sixDigit = (mode: string) => mode.padStart(6, "0");
76const entryType = (mode: string): GitTreeEntry["type"] => (isTreeMode(mode) ? "tree" : mode === "160000" ? "commit" : "blob");
77
78/**
79 * Git data API (blobs, trees, commits, annotated tags), contents writes with
80 * optimistic concurrency, the merges API, archives, languages and a few
81 * Actions and checks extras that need direct access to the repository.
82 */
83export class GitData {
84 constructor(
85 private repo: RepoDO,
86 private env: Env,
87 ) {}
88
89 private get sql() {
90 return this.repo.sql;
91 }
92
93 private get store() {
94 return this.repo.store;
95 }
96
97 private requireWrite(caller: Caller): void {
98 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required");
99 }
100
101 private signature(caller: Caller): Signature {
102 return { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: Math.floor(Date.now() / 1000), timezone: "+0000" };
103 }
104
105 private agentHeaders(caller: Caller): [string, string][] {
106 return caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
107 }
108
109 /** Same push protection as git pushes: blobs with credentials are refused. */
110 private scan(oid: string, content: Uint8Array, caller: Caller): void {
111 const marked: SecretHit[] = [];
112 const hits = scanForSecrets([{ oid, content }], marked);
113 const m = this.repo.meta;
114 if (marked.length) this.repo.emit({ type: "audit", action: "api.secret_marked", actor: caller, target: `${m.owner}/${m.name}`, detail: { kinds: marked.map((h) => h.kind), blob: oid }, at: now() });
115 if (hits.length) {
116 this.repo.emit({ type: "audit", action: "api.blocked_secret", actor: caller, target: `${m.owner}/${m.name}`, detail: { kinds: hits.map((h) => h.kind), blob: oid }, at: now() });
117 throw new HttpError(422, `push protection: possible secret detected (${hits.map((h) => h.kind).join(", ")})`);
118 }
119 }
120
121 objectType(oid: string): ObjectType | null {
122 const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
123 return row ? (CODE_TYPES[row.type] ?? null) : null;
124 }
125
126 /** Refs under a prefix with the type of the object each points at. */
127 refsWithTypes(prefix: string): Array<{ ref: string; sha: string; type: ObjectType | "commit" }> {
128 return this.repo.refs(prefix).map((r) => ({ ref: r.name, sha: r.oid, type: this.objectType(r.oid) ?? "commit" }));
129 }
130
131 // ---------------------------------------------------------------- blobs
132
133 async createBlob(content: Uint8Array, caller: Caller, quotaChecked = false): Promise<{ sha: string; size: number }> {
134 this.requireWrite(caller);
135 if (!quotaChecked) await this.repo.checkStorage(content.length);
136 const oid = await hashObject("blob", content);
137 this.scan(oid, content, caller);
138 await this.store.writeKnown(oid, "blob", content);
139 return { sha: oid, size: content.length };
140 }
141
142 async getBlob(sha: string): Promise<{ sha: string; size: number; content: Uint8Array }> {
143 if (!isOid(sha)) throw new HttpError(404, "blob not found");
144 const o = await this.store.read(sha);
145 if (!o || o.type !== "blob") throw new HttpError(404, "blob not found");
146 return { sha, size: o.content.length, content: o.content };
147 }
148
149 // ---------------------------------------------------------------- trees
150
151 async createTree(baseTree: string | undefined, entries: TreeInput[], caller: Caller): Promise<string> {
152 this.requireWrite(caller);
153 if (!Array.isArray(entries)) throw new HttpError(422, "tree must be an array", { errors: [{ resource: "Tree", code: "invalid", field: "tree" }] });
154 const adding = entries.reduce((n, e) => n + (e?.content === undefined ? 0 : String(e.content).length), 0);
155 if (adding) await this.repo.checkStorage(adding);
156 if (baseTree && this.objectType(baseTree) !== "tree") throw new HttpError(422, "base_tree is not a valid tree oid", { errors: [{ resource: "Tree", code: "invalid", field: "base_tree" }] });
157 type Node = { entries: Map<string, TreeEntry>; children: Map<string, Node> };
158 const load = async (oid: string | null): Promise<Node> => ({ entries: new Map(oid ? (await readTree(this.store, oid)).map((e) => [e.name, e]) : []), children: new Map() });
159 const root = await load(baseTree ?? null);
160 const dirFor = async (parts: string[]): Promise<Node> => {
161 let node = root;
162 for (const part of parts) {
163 let child = node.children.get(part);
164 if (!child) {
165 const existing = node.entries.get(part);
166 child = await load(existing && isTreeMode(existing.mode) ? existing.oid : null);
167 node.children.set(part, child);
168 }
169 node = child;
170 }
171 return node;
172 };
173 for (const e of entries) {
174 const parts = String(e.path ?? "").split("/").filter(Boolean);
175 const name = parts.pop();
176 const why = unsafeTreePath(String(e.path ?? ""));
177 if (!name || why) throw new HttpError(422, `invalid tree path ${JSON.stringify(String(e.path ?? ""))}: ${why ?? "empty path"}`, { errors: [{ resource: "Tree", code: "invalid", field: "path" }] });
178 const mode = String(e.mode ?? "100644");
179 if (!MODES.has(mode)) throw new HttpError(422, `invalid mode ${mode}`, { errors: [{ resource: "Tree", code: "invalid", field: "mode" }] });
180 const dir = await dirFor(parts);
181 if (e.sha === null && e.content === undefined) {
182 dir.entries.delete(name);
183 dir.children.delete(name);
184 continue;
185 }
186 let oid: string;
187 if (e.content !== undefined) {
188 if (mode === "040000" || mode === "160000") throw new HttpError(422, "content can only be used with blob modes");
189 oid = (await this.createBlob(new TextEncoder().encode(String(e.content)), caller, true)).sha;
190 } else {
191 oid = String(e.sha ?? "");
192 if (!isOid(oid)) throw new HttpError(422, `invalid sha for ${e.path}`, { errors: [{ resource: "Tree", code: "invalid", field: "sha" }] });
193 const want = mode === "040000" ? "tree" : mode === "160000" ? null : "blob";
194 if (want && this.objectType(oid) !== want) throw new HttpError(422, `${oid} is not a ${want}`, { errors: [{ resource: "Tree", code: "invalid", field: "sha" }] });
195 }
196 dir.children.delete(name);
197 dir.entries.set(name, { mode: mode === "040000" ? "40000" : mode, name, oid });
198 }
199 const finish = async (node: Node): Promise<string | null> => {
200 for (const [name, child] of node.children) {
201 const oid = await finish(child);
202 if (oid) node.entries.set(name, { mode: "40000", name, oid });
203 else node.entries.delete(name);
204 }
205 if (node.entries.size === 0) return null;
206 return this.store.write("tree", serializeTree([...node.entries.values()]));
207 };
208 return (await finish(root)) ?? (await this.store.write("tree", new Uint8Array(0)));
209 }
210
211 /** A tree by tree SHA, commit SHA or ref; `recursive` lists every entry (truncated after 100k). */
212 async getTree(rev: string, recursive: boolean): Promise<{ sha: string; tree: GitTreeEntry[]; truncated: boolean }> {
213 let sha = isOid(rev) && this.objectType(rev) === "tree" ? rev : null;
214 if (!sha) {
215 const commit = await this.repo.resolve(rev);
216 if (!commit) throw new HttpError(404, "tree not found");
217 sha = (await readCommit(this.store, commit)).tree;
218 }
219 const out: GitTreeEntry[] = [];
220 let truncated = false;
221 const sizes = (oids: string[]) => {
222 const m = new Map<string, number>();
223 for (let i = 0; i < oids.length; i += 100) {
224 const chunk = oids.slice(i, i + 100);
225 for (const r of this.sql.exec<{ oid: string; size: number }>(`SELECT oid, size FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk).toArray()) m.set(r.oid, r.size);
226 }
227 return m;
228 };
229 const walk = async (oid: string, prefix: string) => {
230 const entries = await readTree(this.store, oid);
231 const size = sizes(entries.filter((e) => !isTreeMode(e.mode) && e.mode !== "160000").map((e) => e.oid));
232 for (const e of entries) {
233 if (out.length >= 100_000) {
234 truncated = true;
235 return;
236 }
237 const path = prefix ? `${prefix}/${e.name}` : e.name;
238 const type = entryType(e.mode);
239 out.push({ path, mode: sixDigit(e.mode), type, sha: e.oid, ...(type === "blob" ? { size: size.get(e.oid) ?? 0 } : {}) });
240 if (recursive && type === "tree") await walk(e.oid, path);
241 }
242 };
243 if (sha !== EMPTY_TREE_OID || this.store.has(sha)) await walk(sha, "");
244 return { sha, tree: out, truncated };
245 }
246
247 // ---------------------------------------------------------------- commits and tags
248
249 async createCommit(input: { message?: string; tree?: string; parents?: string[]; author?: SigInput; committer?: SigInput; signature?: string }, caller: Caller): Promise<CommitInfo> {
250 this.requireWrite(caller);
251 if (typeof input.message !== "string") throw new HttpError(422, "message is required", { errors: [{ resource: "Commit", code: "missing_field", field: "message" }] });
252 if (!input.tree || this.objectType(input.tree) !== "tree") throw new HttpError(422, "tree is not a valid tree oid", { errors: [{ resource: "Commit", code: "invalid", field: "tree" }] });
253 const parents = input.parents ?? [];
254 if (!Array.isArray(parents)) throw new HttpError(422, "parents must be an array");
255 for (const p of parents) if (!isOid(String(p)) || this.objectType(String(p)) !== "commit") throw new HttpError(422, `parent ${p} is not a valid commit`, { errors: [{ resource: "Commit", code: "invalid", field: "parents" }] });
256 this.scan("(commit message)", utf8(input.message), caller);
257 const me = this.signature(caller);
258 const author = parseSig(input.author, me, "author");
259 const committer = parseSig(input.committer, input.author ? author : me, "committer");
260 const sha = await this.store.write(
261 "commit",
262 serializeCommit({ tree: input.tree, parents: parents.map(String), author, committer, message: input.message, extraHeaders: this.agentHeaders(caller), gpgsig: input.signature || undefined }),
263 );
264 return this.repo.commitInfo(sha);
265 }
266
267 async gitCommit(sha: string): Promise<CommitInfo> {
268 if (!isOid(sha) || this.objectType(sha) !== "commit") throw new HttpError(404, "commit not found");
269 return toInfo(sha, await readCommit(this.store, sha));
270 }
271
272 async createTag(input: { tag?: string; message?: string; object?: string; type?: string; tagger?: SigInput }, caller: Caller) {
273 this.requireWrite(caller);
274 const tag = String(input.tag ?? "").trim();
275 if (!tag || /[\s~^:?*[\\]|\.\.|^\.|\/$|\.lock$/.test(tag)) throw new HttpError(422, "invalid tag name", { errors: [{ resource: "Tag", code: "invalid", field: "tag" }] });
276 const object = String(input.object ?? "");
277 const actual = isOid(object) ? this.objectType(object) : null;
278 if (!actual) throw new HttpError(422, "object does not exist", { errors: [{ resource: "Tag", code: "invalid", field: "object" }] });
279 const type = (input.type ?? actual) as ObjectType;
280 if (type !== actual) throw new HttpError(422, `object is a ${actual}, not a ${type}`, { errors: [{ resource: "Tag", code: "invalid", field: "type" }] });
281 const tagger = parseSig(input.tagger, this.signature(caller), "tagger");
282 const message = String(input.message ?? "");
283 this.scan("(tag message)", utf8(message), caller);
284 const sha = await this.store.write("tag", serializeTag({ object, type, tag, tagger, message: message.endsWith("\n") || !message ? message : `${message}\n` }));
285 return this.getTag(sha);
286 }
287
288 async getTag(sha: string) {
289 if (!isOid(sha) || this.objectType(sha) !== "tag") throw new HttpError(404, "tag not found");
290 const o = await this.store.read(sha);
291 const t = parseTag(o!.content);
292 return { sha, tag: t.tag, message: t.message, tagger: t.tagger ?? null, object: { sha: t.object, type: t.type } };
293 }
294
295 // ---------------------------------------------------------------- contents
296
297 /** Create or update one file; `sha` must name the file's current blob when it exists (GitHub's rule). */
298 async putContents(
299 input: { path: string; content: Uint8Array; message: string; branch?: string; sha?: string; author?: { name: string; email: string }; committer?: { name: string; email: string } },
300 caller: Caller,
301 ): Promise<{ created: boolean; commit: CommitInfo; blob: { sha: string; size: number } }> {
302 this.requireWrite(caller);
303 if (!input.message) throw new HttpError(422, "message is required", { errors: [{ resource: "Commit", code: "missing_field", field: "message" }] });
304 const branch = input.branch || this.repo.meta.defaultBranch;
305 const head = this.repo.getRef(`refs/heads/${branch}`);
306 const existing = head ? await resolvePath(this.store, (await readCommit(this.store, head)).tree, input.path) : null;
307 if (existing && isTreeMode(existing.mode)) throw new HttpError(422, `${input.path} is a directory`);
308 if (existing && !input.sha) throw new HttpError(422, "Invalid request.\n\n\"sha\" wasn't supplied.", { errors: [{ resource: "Contents", code: "missing_field", field: "sha" }] });
309 if (existing && input.sha !== existing.oid) throw new HttpError(409, `${input.path} does not match ${input.sha}`);
310 if (!existing && input.sha) throw new HttpError(409, `${input.path} does not exist at ${branch}`);
311 const oid = await hashObject("blob", input.content);
312 const res = await this.repo.commitFiles({ branch, message: input.message, changes: [{ path: input.path, content: input.content }], expectedHeadSha: head ?? undefined, author: input.author, committer: input.committer }, caller);
313 return { created: !existing, commit: await this.repo.commitInfo(res.sha), blob: { sha: oid, size: input.content.length } };
314 }
315
316 async deleteContents(input: { path: string; message: string; branch?: string; sha?: string; author?: { name: string; email: string }; committer?: { name: string; email: string } }, caller: Caller): Promise<CommitInfo> {
317 this.requireWrite(caller);
318 if (!input.message) throw new HttpError(422, "message is required");
319 const branch = input.branch || this.repo.meta.defaultBranch;
320 const head = this.repo.getRef(`refs/heads/${branch}`);
321 if (!head) throw new HttpError(404, `branch ${branch} not found`);
322 const existing = await resolvePath(this.store, (await readCommit(this.store, head)).tree, input.path);
323 if (!existing || isTreeMode(existing.mode)) throw new HttpError(404, "file not found");
324 if (!input.sha) throw new HttpError(422, "Invalid request.\n\n\"sha\" wasn't supplied.", { errors: [{ resource: "Contents", code: "missing_field", field: "sha" }] });
325 if (input.sha !== existing.oid) throw new HttpError(409, `${input.path} does not match ${input.sha}`);
326 const res = await this.repo.commitFiles({ branch, message: input.message, changes: [{ path: input.path, delete: true }], expectedHeadSha: head, author: input.author, committer: input.committer }, caller);
327 return this.repo.commitInfo(res.sha);
328 }
329
330 /** The README in a directory (GET /readme). */
331 async readme(rev: string, dir = ""): Promise<{ path: string; sha: string; size: number; content: Uint8Array; commit: string } | null> {
332 const t = await this.repo.tree(rev, dir);
333 if (!t) return null;
334 const candidates = t.items.filter((i) => i.type === "blob" && /^readme(\.[A-Za-z0-9]+)?$/i.test(i.name));
335 const pick = candidates.find((i) => /\.(md|markdown)$/i.test(i.name)) ?? candidates[0];
336 if (!pick) return null;
337 const b = await this.repo.blob(t.sha, pick.path);
338 return b ? { path: pick.path, sha: b.oid, size: b.size, content: b.content, commit: b.sha } : null;
339 }
340
341 // ---------------------------------------------------------------- merges API
342
343 /** Merge `head` into the `base` branch. Returns null when there is nothing to merge. */
344 async merge(input: { base?: string; head?: string; commit_message?: string }, caller: Caller): Promise<CommitInfo | null> {
345 this.requireWrite(caller);
346 const base = String(input.base ?? "");
347 const ref = `refs/heads/${base}`;
348 const baseSha = this.repo.getRef(ref);
349 if (!baseSha) throw new HttpError(404, "Base does not exist");
350 const headSha = await this.repo.resolve(String(input.head ?? ""));
351 if (!headSha) throw new HttpError(404, "Head does not exist");
352 if (await isAncestor(this.store, headSha, baseSha)) return null;
353 const mb = await mergeBase(this.store, baseSha, headSha);
354 const [bt, ht, mt] = await Promise.all([readCommit(this.store, baseSha), readCommit(this.store, headSha), mb ? readCommit(this.store, mb) : null]);
355 const merged = await mergeTrees(this.store, mt?.tree ?? null, bt.tree, ht.tree);
356 if (merged.conflicts.length || !merged.tree) throw new HttpError(409, "Merge conflict");
357 const sig = this.signature(caller);
358 const message = input.commit_message || `Merge ${input.head} into ${base}`;
359 const sha = await this.store.write("commit", serializeCommit({ tree: merged.tree, parents: [baseSha, headSha], author: sig, committer: sig, message: message.endsWith("\n") ? message : `${message}\n`, extraHeaders: this.agentHeaders(caller) }));
360 const check = await this.repo.checkRefUpdate(ref, baseSha, sha, caller);
361 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
362 if (!this.repo.casRef(ref, baseSha, sha)) throw new HttpError(409, "base branch moved; try again");
363 await this.repo.afterRefUpdate(ref, baseSha, sha, caller);
364 return this.repo.commitInfo(sha);
365 }
366
367 // ---------------------------------------------------------------- languages and archives
368
369 /** Bytes per language at the default branch, cached per commit. */
370 async languages(): Promise<Record<string, number>> {
371 const sha = await this.repo.resolve("HEAD");
372 if (!sha) return {};
373 const cached = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'languages'").toArray()[0];
374 if (cached) {
375 const c = JSON.parse(cached.value) as { sha: string; data: Record<string, number> };
376 if (c.sha === sha) return c.data;
377 }
378 const out: Record<string, number> = {};
379 try {
380 await this.walkTree((await readCommit(this.store, sha)).tree, "", (e, path) => {
381 if (isTreeMode(e.mode)) return !VENDORED.test(`${path}/`);
382 if (e.mode === "160000" || e.mode === "120000" || VENDORED.test(path)) return false;
383 const lang = LANGUAGE_FILES[e.name] ?? LANGUAGES[e.name.includes(".") ? e.name.split(".").pop()!.toLowerCase() : ""];
384 if (!lang) return false;
385 const size = this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0]?.size ?? 0;
386 out[lang] = (out[lang] ?? 0) + size;
387 return false;
388 });
389 } catch (e) {
390 // A huge (or hostile) tree: report what was counted before the budget ran out.
391 if (!(e instanceof HttpError && e.status === 413)) throw e;
392 }
393 const sorted = Object.fromEntries(Object.entries(out).sort((a, b) => b[1] - a[1]));
394 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('languages', ?)", JSON.stringify({ sha, data: sorted }));
395 return sorted;
396 }
397
398 archiveKey(sha: string, format: "zip" | "tar.gz"): string {
399 // Outside repos/: a cache the lifecycle rules may expire (lib/storage-keys.ts).
400 return archiveKey(this.repo.meta.repoId, sha, format);
401 }
402
403 /**
404 * Visit every entry under a tree, depth first. `visit` returns whether to
405 * descend into a subtree. Every entry visited counts against one budget
406 * (a subtree reused a thousand times costs a thousand times), so a small
407 * tree of shared subtrees can't make a walk explode.
408 */
409 private async walkTree(tree: string, prefix: string, visit: (e: TreeEntry, path: string) => boolean | Promise<boolean>, budget = { left: MAX_TREE_ENTRIES }, depth = 0): Promise<void> {
410 if (depth > MAX_TREE_DEPTH) throw new HttpError(413, "the tree is nested too deeply");
411 for (const e of await readTree(this.store, tree)) {
412 if (--budget.left < 0) throw new HttpError(413, `the tree has more than ${MAX_TREE_ENTRIES.toLocaleString("en-US")} entries; clone it with git instead`);
413 const path = prefix ? `${prefix}/${e.name}` : e.name;
414 if ((await visit(e, path)) && isTreeMode(e.mode)) await this.walkTree(e.oid, path, visit, budget, depth + 1);
415 }
416 }
417
418 /** Allow at most ARCHIVE_BUILDS_PER_HOUR fresh archive builds per repository. */
419 private countArchiveBuild(): void {
420 const hourAgo = Date.now() - 3600_000;
421 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'archive_builds'").toArray()[0];
422 const recent = (row ? (JSON.parse(row.value) as number[]) : []).filter((t) => t > hourAgo);
423 if (recent.length >= ARCHIVE_BUILDS_PER_HOUR) throw new HttpError(429, "too many archives were built for this repository in the last hour; try again later or clone it with git");
424 recent.push(Date.now());
425 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('archive_builds', ?)", JSON.stringify(recent));
426 }
427
428 /**
429 * Build a source archive of a commit into R2 (top directory `<owner>-<repo>-<sha7>/`,
430 * like GitHub). Entries stream through the compressor into R2, so memory stays
431 * around one file plus one upload part. Archives are a cache: R2 may evict them
432 * and they are rebuilt on the next download.
433 */
434 async buildArchive(sha: string, format: "zip" | "tar.gz"): Promise<{ key: string; size: number }> {
435 const commit = await readCommit(this.store, sha);
436 this.countArchiveBuild();
437 const m = this.repo.meta;
438 const prefix = `${m.owner}-${m.name}-${sha.slice(0, 7)}`;
439 const mtime = commit.committer.timestamp;
440 const key = this.archiveKey(sha, format);
441 const sink = new R2Sink(this.env.BUCKET, key, format === "zip" ? "application/zip" : "application/gzip", MAX_ARCHIVE_BYTES + 16 * 1024 * 1024, "repository is too large for an on-the-fly archive; clone it with git instead");
442 let total = 0;
443 // Each entry, in tree order; unsafe names (and anything under them) are left out.
444 const entries = async (emit: (e: ArchiveEntry) => Promise<void>) => {
445 await emit({ path: `${prefix}/`, dir: true });
446 await this.walkTree(commit.tree, prefix, async (e, path) => {
447 if (unsafeTreeName(e.name)) return false;
448 if (isTreeMode(e.mode)) {
449 await emit({ path: `${path}/`, dir: true });
450 return true;
451 }
452 if (e.mode === "160000") {
453 await emit({ path: `${path}/`, dir: true });
454 return false;
455 }
456 const o = await this.store.read(e.oid);
457 if (!o) return false;
458 total += o.content.length;
459 if (total > MAX_ARCHIVE_BYTES) throw new HttpError(413, "repository is too large for an on-the-fly archive; clone it with git instead");
460 await emit({ path, mode: e.mode, content: o.content });
461 return false;
462 });
463 };
464 let writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
465 try {
466 if (format === "zip") {
467 const zw = new ZipWriter(mtime);
468 await entries(async (e) => {
469 let parts: Uint8Array[];
470 try {
471 parts = await zw.entry(e);
472 } catch (err) {
473 if (err instanceof RangeError) throw new HttpError(413, "this repository has too many files for a zip archive (more than 65,535); download the .tar.gz archive or clone it with git");
474 throw err;
475 }
476 for (const part of parts) await sink.write(part);
477 });
478 for (const part of zw.end(sha)) await sink.write(part);
479 } else {
480 const gz = new CompressionStream("gzip");
481 const w = gz.writable.getWriter();
482 writer = w;
483 const reader = gz.readable.getReader();
484 const pump = (async () => {
485 for (;;) {
486 const { done, value } = await reader.read();
487 if (done) return;
488 await sink.write(value);
489 }
490 })();
491 // If R2 or the size limit stops the pump, fail the writes instead of waiting on backpressure.
492 const stopped = pump.catch(async (err) => {
493 await w.abort(err).catch(() => undefined);
494 throw err;
495 });
496 const put = async (parts: Uint8Array[]) => {
497 for (const part of parts) if (part.length) await w.write(part);
498 };
499 await put(tarStart(mtime, sha));
500 await entries((e) => put(tarEntry(e, mtime)));
501 await put([tarEnd()]);
502 await w.close();
503 await stopped;
504 }
505 return { key, size: await sink.close() };
506 } catch (e) {
507 await writer?.abort(e).catch(() => undefined);
508 await sink.abort();
509 throw e;
510 }
511 }
512
513 // ---------------------------------------------------------------- Actions extras
514
515 /** A run's logs as a zip with one text file per job (GET /actions/runs/:id/logs). */
516 async runLogsZip(runId: string): Promise<Uint8Array> {
517 const run = this.repo.actions.getRun(runId);
518 const entries: ArchiveEntry[] = [];
519 let total = 0;
520 for (const [i, j] of run.jobs.entries()) {
521 const { lines } = await this.repo.actions.logs(j.id);
522 const text = new TextEncoder().encode(lines.map((l) => `${l.t} ${l.text}`).join("\n") + (lines.length ? "\n" : ""));
523 total += text.length;
524 if (total > 24 * 1024 * 1024) break;
525 entries.push({ path: `${i + 1}_${j.name.replace(/[\\/:*?"<>|]+/g, "_").slice(0, 100)}.txt`, mode: "100644", content: text });
526 }
527 return zip(entries, Math.floor(Date.parse(run.updatedAt) / 1000) || Math.floor(Date.now() / 1000));
528 }
529
530 /** The run id of attempt `n` of a run (re-runs are stored as new runs with the same number). */
531 runAttemptId(runId: string, attempt: number): string {
532 const r = this.repo.actions.getRun(runId);
533 const row = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE workflow_path = ? AND run_number = ? AND attempt = ? ORDER BY created_at DESC LIMIT 1", r.workflowPath, r.number, attempt).toArray()[0];
534 if (!row) throw new HttpError(404, "run attempt not found");
535 return row.id;
536 }
537
538 // ---------------------------------------------------------------- check runs and suites
539
540 /** A check run by GitHub's numeric id, Diffinery's own, or the Actions job that reports it (a job's `check_run_url`). */
541 check(raw: string): Check {
542 const row = /^\d{1,15}$/.test(raw)
543 ? this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE seq = ?", Number(raw)).toArray()[0]
544 : (this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE id = ?", raw).toArray()[0] ??
545 this.sql.exec<{ sha: string; id: string }>("SELECT sha, id FROM checks WHERE job_id = ? ORDER BY seq DESC LIMIT 1", raw).toArray()[0]);
546 const c = row ? this.repo.collab.checks(row.sha).find((x) => x.id === row.id) : undefined;
547 if (!c) throw new HttpError(404, "check run not found");
548 return c;
549 }
550
551 /** PATCH /check-runs/:id: status, conclusion, output (annotations accumulate, like GitHub). */
552 updateCheck(
553 id: string,
554 input: {
555 name?: string;
556 details_url?: string;
557 external_id?: string;
558 status?: string;
559 conclusion?: string;
560 started_at?: string;
561 completed_at?: string;
562 output?: { title?: string; summary?: string; text?: string; annotations?: unknown[] };
563 },
564 caller: Caller,
565 ): Check {
566 this.requireWrite(caller);
567 const c = this.check(id);
568 if (c.app === ACTIONS_APP) throw new HttpError(403, "check runs from Diffinery Actions can't be changed over the API");
569 const own = c.creatorId ? c.creatorId === caller.id : c.app.toLowerCase() === caller.login.toLowerCase();
570 if (!own) throw new HttpError(403, "only the app that created a check run can update it");
571 if (input.status && !["queued", "in_progress", "completed", "waiting", "requested", "pending"].includes(input.status)) throw new HttpError(422, "invalid status", { errors: [{ resource: "CheckRun", code: "invalid", field: "status" }] });
572 const conclusions = ["action_required", "cancelled", "failure", "neutral", "success", "skipped", "stale", "timed_out"];
573 if (input.conclusion && !conclusions.includes(input.conclusion)) throw new HttpError(422, "invalid conclusion", { errors: [{ resource: "CheckRun", code: "invalid", field: "conclusion" }] });
574 if (input.details_url && !/^https?:\/\//i.test(input.details_url)) throw new HttpError(422, "details_url must be an http(s) URL");
575 const status = input.conclusion ? "completed" : (input.status ?? c.status);
576 if (status === "completed" && !(input.conclusion ?? c.conclusion)) throw new HttpError(422, "conclusion is required when status is completed", { errors: [{ resource: "CheckRun", code: "missing_field", field: "conclusion" }] });
577 const output = { ...(c.output ?? {}) } as Record<string, unknown>;
578 if (input.output) {
579 for (const k of ["title", "summary", "text"] as const) if (input.output[k] !== undefined) output[k] = String(input.output[k]).slice(0, 65535);
580 if (Array.isArray(input.output.annotations)) {
581 const all = [...((output.annotations as unknown[]) ?? []), ...input.output.annotations.slice(0, 50)];
582 output.annotations = all.slice(0, 1000);
583 }
584 }
585 if (input.external_id !== undefined) output.external_id = String(input.external_id);
586 const t = now();
587 this.sql.exec(
588 "UPDATE checks SET name = ?, status = ?, conclusion = ?, details_url = ?, output = ?, started_at = ?, completed_at = ? WHERE id = ?",
589 input.name ?? c.name,
590 status,
591 status === "completed" ? (input.conclusion ?? c.conclusion) : null,
592 input.details_url ?? c.detailsUrl,
593 JSON.stringify(output),
594 input.started_at ?? c.startedAt ?? (status !== "queued" ? t : null),
595 status === "completed" ? (input.completed_at ?? c.completedAt ?? t) : null,
596 c.id,
597 );
598 for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", c.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" });
599 if (status === "completed") this.repo.collab.checkCompleted(c.sha);
600 return this.check(c.id);
601 }
602
603 /** Check suites, synthesised: one per workflow run, or per app for external check runs. */
604 checkSuites(sha: string): Array<{ id: string; app: string; headSha: string; status: string; conclusion: string | null; runs: Check[] }> {
605 const groups = new Map<string, Check[]>();
606 for (const c of this.repo.collab.checks(sha)) {
607 const id = c.runId ?? `app-${c.app}-${sha}`;
608 groups.set(id, [...(groups.get(id) ?? []), c]);
609 }
610 return [...groups].map(([id, runs]) => {
611 const done = runs.every((r) => r.status === "completed");
612 const failed = runs.some((r) => ["failure", "timed_out", "cancelled", "action_required"].includes(r.conclusion ?? ""));
613 return { id, app: runs[0]!.app, headSha: sha, status: done ? "completed" : runs.some((r) => r.status === "in_progress") ? "in_progress" : "queued", conclusion: done ? (failed ? "failure" : "success") : null, runs };
614 });
615 }
616
617 checkSuite(id: string): ReturnType<GitData["checkSuites"]>[number] {
618 const row = id.startsWith("app-")
619 ? this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE ? = 'app-' || app || '-' || sha LIMIT 1", id).toArray()[0]
620 : this.sql.exec<{ sha: string }>("SELECT sha FROM checks WHERE run_id = ? LIMIT 1", id).toArray()[0];
621 const suite = row ? this.checkSuites(row.sha).find((s) => s.id === id) : undefined;
622 if (!suite) throw new HttpError(404, "check suite not found");
623 return suite;
624 }
625}