| 1 | import { ACTIONS_APP } from "./required.js"; |
| 2 | import { |
| 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"; |
| 23 | import type { Env } from "../env.js"; |
| 24 | import { scanForSecrets, type SecretHit } from "../git/secret-scan.js"; |
| 25 | import { ZipWriter, tarEnd, tarEntry, tarStart, zip, type ArchiveEntry } from "../lib/archive.js"; |
| 26 | import { R2Sink } from "../lib/r2-sink.js"; |
| 27 | import { unsafeTreeName, unsafeTreePath } from "../lib/tree-names.js"; |
| 28 | import { HttpError, noreplyEmail, now } from "../lib/util.js"; |
| 29 | import { archiveKey } from "../lib/storage-keys.js"; |
| 30 | import type { Check } from "./collab.js"; |
| 31 | import { toInfo, type CommitInfo, type RepoDO } from "./RepoDO.js"; |
| 32 | import { atLeast, type Caller } from "./types.js"; |
| 33 | |
| 34 | export type SigInput = { name?: string; email?: string; date?: string }; |
| 35 | export type TreeInput = { path?: string; mode?: string; type?: string; sha?: string | null; content?: string }; |
| 36 | export type GitTreeEntry = { path: string; mode: string; type: "blob" | "tree" | "commit"; sha: string; size?: number }; |
| 37 | |
| 38 | const MODES = new Set(["100644", "100755", "040000", "160000", "120000"]); |
| 39 | /** Uncompressed file content an archive may hold; bigger repositories should be cloned. */ |
| 40 | const MAX_ARCHIVE_BYTES = 24 * 1024 * 1024; |
| 41 | /** Tree entries a walk may visit (repeated subtrees count every time), against tree bombs. */ |
| 42 | const MAX_TREE_ENTRIES = 100_000; |
| 43 | const MAX_TREE_DEPTH = 128; |
| 44 | /** Archive builds per repository per hour (cached archives don't count). */ |
| 45 | const ARCHIVE_BUILDS_PER_HOUR = 20; |
| 46 | |
| 47 | /** Language by file extension, for GET /languages (programming and markup languages, like linguist). */ |
| 48 | const 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 | }; |
| 57 | const LANGUAGE_FILES: Record<string, string> = { Dockerfile: "Dockerfile", Makefile: "Makefile", "CMakeLists.txt": "CMake", Rakefile: "Ruby", Gemfile: "Ruby" }; |
| 58 | const VENDORED = /(^|\/)(node_modules|vendor|third_party|dist|build|\.yarn)\//; |
| 59 | |
| 60 | function 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 | |
| 75 | const sixDigit = (mode: string) => mode.padStart(6, "0"); |
| 76 | const 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 | */ |
| 83 | export 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: noreplyEmail(this.env.PUBLIC_URL, caller.login), 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 | } |