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.

2178 lines · 104.5 KBRaw History
1import { DurableObject } from "cloudflare:workers";
2import { normalizeCheckSources, requiredCheck } from "./required.js";
3import { REVIEW_APP, REVIEW_CHECK } from "../agentic/review.js";
4import { unsafeTreePath } from "../lib/tree-names.js";
5import {
6 CODE_TYPES,
7 EMPTY_TREE_OID,
8 ZERO_OID,
9 applyChanges,
10 DiffBudget,
11 diffTrees,
12 fileDiff,
13 formatUnified,
14 fromUtf8,
15 isAncestor,
16 isBinary,
17 isOid,
18 isTreeMode,
19 mergeBase,
20 parseCommit,
21 packStream,
22 parsePack,
23 parseTree,
24 planFetch,
25 peelToCommit,
26 readCommit,
27 readTree,
28 resolvePath,
29 TooMuchMemory,
30 MAX_HELD_BYTES,
31 serializeCommit,
32 walkCommits,
33 type Commit,
34 type FileChange,
35 type Signature,
36 utf8,
37} from "@diffinery/git-core";
38import type { DiffineryEvent } from "@diffinery/shared";
39import type { Env } from "../env.js";
40import {
41 advertiseReceivePack,
42 advertiseUploadPackV2,
43 errorPacket,
44 handleUploadPackV2,
45 parseReceivePack,
46 reportStatus,
47 type ReceiveRequest,
48 resetByDeploy,
49 unpackFailure,
50 type RefEntry,
51 type RefResult,
52} from "../git/protocol.js";
53import { memorySource, packedContents, readStagedHead, repositoryBases, stagedContents, streamedContents, type PushContents } from "../git/push-contents.js";
54import { scanForSecrets, secretBypassReason, type SecretHit } from "../git/secret-scan.js";
55import { Directory } from "../lib/directory.js";
56import { GHOST, HttpError, isValidRefName, noreplyEmail, now } from "../lib/util.js";
57import { R2Sink } from "../lib/r2-sink.js";
58import { archivePrefix, deletePrefixes, pushBodyKey, repoPrefixes, type SpilledBody } from "../lib/storage-keys.js";
59import { MemoryPool, type Lease } from "../lib/memory-pool.js";
60import { storageRefusal } from "../lib/quotas.js";
61import { Actions } from "./actions.js";
62import { AutoMerge, isPerson } from "./auto-merge.js";
63import { Collab } from "./collab.js";
64import { Deployments } from "./deployments.js";
65import { triggerWorkflows } from "./extra-triggers.js";
66import { GitData } from "./gitdata.js";
67import { IMPORT_REF_PREFIX, RepoImports } from "./imports.js";
68import { RepoObjectStore } from "./objects.js";
69import { MergeQueue, QUEUE_BRANCH_PREFIX, normalizeMergeQueue } from "./queue.js";
70import { Releases } from "./releases.js";
71import { REPO_SCHEMA, migrateRepoSchema } from "./schema.js";
72import { Runtime } from "./runtime.js";
73import { TokenPolicy } from "./token-policy.js";
74import { searchIssues } from "./search.js";
75import { MERGE_CHECKS_PER_SUMMARY, needsMergeCheck, repoToday } from "../agentic/today-repo.js";
76import { Social } from "./social.js";
77import { Statuses } from "./statuses.js";
78import { MAX_LOG_DEPTH, atLeast, globToRegExp, type AutoMergeAgent, type Caller, type ProtectionRules, type ProtectionRulesInput, type RepoMeta } from "./types.js";
79
80const MAX_PUSH_BYTES = 100 * 1024 * 1024;
81/** Agents one branch rule may auto-merge pull requests from (ProtectionRules.autoMergeAgents). */
82const MAX_AUTO_MERGE_AGENTS = 20;
83/** Pushed blobs this large are hashed, scanned and stored from their compressed bytes, never held inflated. */
84const LARGE_OBJECT_BYTES = 4 * 1024 * 1024;
85/**
86 * What pushes may hold at once in this object while they unpack: one push may hold MAX_HELD_BYTES, besides
87 * a body of up to 16 MiB passed in memory (http/git.ts IN_MEMORY_PUSH), and the object has 128 MB. Without
88 * it, two large pushes unpacking at once could go past that, and the reset drops every request in flight
89 * here, other pushes and fetches included. Each push reserves what it may hold and waits its turn.
90 */
91const PUSH_MEMORY_BYTES = MAX_HELD_BYTES + 16 * 1024 * 1024;
92/** A push with a body this small unpacks within SMALL_PUSH_HELD, beside others; one that needs more waits for a full share. */
93const SMALL_PUSH_BYTES = 1024 * 1024;
94const SMALL_PUSH_HELD = 8 * 1024 * 1024;
95/** A purge walks every reachable commit and tree while holding the repository: bounded so it finishes in time. */
96export const MAX_PURGE_OBJECTS = 300_000;
97/** Storage key that marks a deleted repository whose R2 objects are still being deleted. */
98const PURGE_KEY = "purge";
99/** How often jobs waiting on the account's hosted-job quota are retried. */
100const HELD_RETRY_MS = 60_000;
101
102export type TreeItem = {
103 name: string;
104 path: string;
105 type: "tree" | "blob" | "commit";
106 mode: string;
107 oid: string;
108 size?: number;
109};
110
111export type CommitInfo = {
112 sha: string;
113 tree: string;
114 parents: string[];
115 author: Signature;
116 committer: Signature;
117 message: string;
118 signed: boolean;
119};
120
121/**
122 * One Durable Object per repository. It is the single writer for the repo's
123 * refs, Git objects, issues, pull requests, reviews, checks and workflow runs,
124 * which gives strongly consistent ref updates and merges without locks.
125 */
126/** How long a live-update socket lives before the page must reconnect and pass the access check again. */
127const LIVE_REAUTH_MS = 10 * 60 * 1000;
128/** The close code that asks a page to reconnect at once (not a failure: no backoff, no reload). */
129export const LIVE_REAUTH_CODE = 4001;
130
131/** A row of what someone wrote (RepoDO.authoredBy): column name to value. */
132type AuthoredRow = Record<string, string | number | null>;
133
134export class RepoDO extends DurableObject<Env> {
135 readonly sql: SqlStorage;
136 store!: RepoObjectStore;
137 readonly collab: Collab;
138 readonly actions: Actions;
139 readonly queue: MergeQueue;
140 readonly autoMerge: AutoMerge;
141 readonly runtime: Runtime;
142 readonly policy: TokenPolicy;
143 private metaCache: RepoMeta | null = null;
144
145 constructor(ctx: DurableObjectState, env: Env) {
146 super(ctx, env);
147 this.sql = ctx.storage.sql;
148 this.sql.exec(REPO_SCHEMA);
149 migrateRepoSchema(this.sql);
150 const meta = this.readMeta();
151 if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId, () => this.storageChanged());
152 this.collab = new Collab(this, env);
153 this.actions = new Actions(this, env);
154 this.queue = new MergeQueue(this);
155 this.autoMerge = new AutoMerge(this, env);
156 this.runtime = new Runtime(this, env);
157 this.policy = new TokenPolicy(this);
158 }
159
160 // ---------------------------------------------------------------- meta
161
162 private readMeta(): RepoMeta | null {
163 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0];
164 this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null;
165 return this.metaCache;
166 }
167
168 /** The noreply address of an account's commits made here (lib/util.ts noreplyEmail). */
169 noreply(login: string): string {
170 return noreplyEmail(this.env.PUBLIC_URL, login);
171 }
172
173 get meta(): RepoMeta {
174 const m = this.metaCache ?? this.readMeta();
175 if (!m) throw new HttpError(404, "repository not initialised");
176 return m;
177 }
178
179 async init(meta: RepoMeta): Promise<void> {
180 // A repository created now (not one whose meta is updated) starts with read-only workflow tokens.
181 if (!this.readMeta()) this.policy.initNewRepo();
182 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta));
183 this.metaCache = meta;
184 this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId, () => this.storageChanged());
185 }
186
187 async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> {
188 const next = { ...this.meta, ...patch };
189 await this.init(next);
190 return next;
191 }
192
193 /**
194 * Whether the repository is archived (read-only). The Worker refuses writes to an archived
195 * repository, but work this object does by itself (auto-merge, the merge queue) must ask here.
196 */
197 async isArchived(): Promise<boolean> {
198 const known = this.meta.archived;
199 if (typeof known === "boolean") return known;
200 const row = await this.env.DB.prepare("SELECT archived FROM repos WHERE id = ?").bind(this.meta.repoId).first<{ archived: number }>();
201 // setArchived may have run while D1 answered; what it set wins.
202 if (typeof this.meta.archived === "boolean") return this.meta.archived;
203 const archived = !!row?.archived;
204 await this.updateMeta({ archived });
205 return archived;
206 }
207
208 /** The repository was archived or unarchived (D1 already says so). Archiving ends auto-merge and empties the merge queues. */
209 async setArchived(archived: boolean): Promise<void> {
210 await this.updateMeta({ archived });
211 if (!archived) return;
212 await this.autoMerge.repositoryArchived();
213 await this.queue.repositoryArchived();
214 }
215
216 /**
217 * Wipe all storage (repository deletion), then its R2 objects: git objects, LFS, release
218 * assets, caches, artifacts, logs and archives. One call can't delete more than about
219 * 450,000 objects, so the alarm keeps going until they are all gone.
220 */
221 async destroy(): Promise<void> {
222 const repoId = this.readMeta()?.repoId;
223 await this.ctx.storage.deleteAlarm();
224 await this.ctx.storage.deleteAll();
225 this.metaCache = null;
226 if (!repoId) return;
227 await this.ctx.storage.put(PURGE_KEY, repoId);
228 await this.purge(repoId);
229 }
230
231 /** Delete a deleted repository's R2 objects; returns false while some remain (the alarm retries). */
232 private async purge(repoId: string): Promise<void> {
233 const done = await deletePrefixes(this.env.BUCKET, repoPrefixes(repoId)).catch((e) => {
234 console.error("could not delete the repository's objects", repoId, e);
235 return false;
236 });
237 if (done) await this.ctx.storage.deleteAll();
238 else await this.ctx.storage.setAlarm(Date.now() + 5_000);
239 }
240
241 async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> {
242 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
243 return {
244 objects: this.store.count(),
245 bytes: this.store.sizeBytes(),
246 refs: q("SELECT count(*) AS n FROM refs"),
247 openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"),
248 openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"),
249 };
250 }
251
252 // ---------------------------------------------------------------- deleted and exported accounts (accounts/)
253
254 /**
255 * After an account is deleted its work here stays, attributed to the `ghost` user as on GitHub: issues and
256 * pull requests, comments, reviews, releases, statuses, deployments, events, workflow runs and reactions.
257 * Self-hosted runners it registered here (by login) are removed. Returns how many rows changed.
258 */
259 async forgetAccounts(ids: string[], logins: string[]): Promise<number> {
260 if (!ids.length) return 0;
261 const inIds = ids.map(() => "?").join(", ");
262 let changed = 0;
263 const run = (q: string, ...b: unknown[]) => void (changed += this.sql.exec(q, ...b).rowsWritten);
264 const people: Array<[table: string, prefix: string]> = [
265 ["issues", "author"],
266 ["comments", "author"],
267 ["reviews", "author"],
268 ["releases", "author"],
269 ["statuses", "creator"],
270 ["deployments", "creator"],
271 ["deployment_statuses", "creator"],
272 ["issue_events", "actor"],
273 ["checks", "creator"],
274 ["release_assets", "uploader"],
275 ["reactions", "user"],
276 ];
277 for (const [table, p] of people) {
278 // Reactions are one per person: where the ghost already reacted the same way, the deleted account's goes.
279 run(`UPDATE OR IGNORE ${table} SET ${p}_id = ?, ${p}_login = ?, ${p}_kind = ? WHERE ${p}_id IN (${inIds})`, GHOST.id, GHOST.login, GHOST.kind, ...ids);
280 }
281 run(`DELETE FROM reactions WHERE user_id IN (${inIds})`, ...ids);
282 run(`UPDATE deployment_reviews SET user_id = ?, user_login = ? WHERE user_id IN (${inIds})`, GHOST.id, GHOST.login, ...ids);
283 run(`UPDATE runs SET actor = json_object('id', ?, 'login', ?, 'kind', ?) WHERE json_extract(actor, '$.id') IN (${inIds})`, GHOST.id, GHOST.login, GHOST.kind, ...ids);
284 if (logins.length) {
285 const lower = logins.map((l) => l.toLowerCase());
286 const inLogins = lower.map(() => "?").join(", ");
287 run(`UPDATE pulls SET merged_by = ? WHERE lower(merged_by) IN (${inLogins})`, GHOST.login, ...lower);
288 run(`DELETE FROM runners WHERE lower(created_by) IN (${inLogins})`, ...lower);
289 run(`DELETE FROM runner_registrations WHERE lower(created_by) IN (${inLogins})`, ...lower);
290 }
291 return changed;
292 }
293
294 /** What these accounts wrote here, for an account export: issues and pull requests they opened, their comments and reviews (at most `limit` of each). */
295 async authoredBy(ids: string[], limit = 5000): Promise<{ issues: AuthoredRow[]; comments: AuthoredRow[]; reviews: AuthoredRow[] }> {
296 if (!ids.length || !this.readMeta()) return { issues: [], comments: [], reviews: [] };
297 const inIds = ids.map(() => "?").join(", ");
298 const rows = (q: string) => this.sql.exec<AuthoredRow>(q, ...ids, limit).toArray();
299 return {
300 issues: rows(`SELECT number, kind, title, body, state, state_reason, created_at, updated_at, closed_at FROM issues WHERE author_id IN (${inIds}) ORDER BY number LIMIT ?`),
301 comments: rows(`SELECT id, number, body, path, line, created_at, updated_at FROM comments WHERE author_id IN (${inIds}) ORDER BY created_at LIMIT ?`),
302 reviews: rows(`SELECT id, number, state, body, commit_id, submitted_at FROM reviews WHERE author_id IN (${inIds}) ORDER BY submitted_at LIMIT ?`),
303 };
304 }
305
306 /**
307 * Write a Git bundle (version 2) of the repository's branches and tags to R2 at `key`, for an account export.
308 * The pack streams out one object at a time, as a clone's does. Null for an empty repository; a bundle past
309 * `limit` bytes is refused with a 413 and nothing is kept.
310 */
311 async writeBundle(key: string, limit: number): Promise<{ size: number; refs: number } | null> {
312 if (!this.readMeta()) return null;
313 const refs = this.refs().filter((r) => r.name.startsWith("refs/heads/") || r.name.startsWith("refs/tags/"));
314 if (!refs.length) return null;
315 const plan = await planFetch(this.store, [...new Set(refs.map((r) => r.oid))], []);
316 const head = this.getRef(this.headRef);
317 const sink = new R2Sink(this.env.BUCKET, key, "application/x-git-bundle", limit, "the repository is too large to bundle");
318 const store = this.store;
319 async function* entries() {
320 for await (const raw of store.readRawAll(plan.oids)) yield { type: raw.type, size: raw.size, deflated: raw.deflated };
321 }
322 try {
323 await sink.write(utf8(`# v2 git bundle\n${head ? `${head} HEAD\n` : ""}${refs.map((r) => `${r.oid} ${r.name}\n`).join("")}\n`));
324 for await (const part of packStream(plan.oids.length, entries())) await sink.write(part);
325 return { size: await sink.close(), refs: refs.length };
326 } catch (e) {
327 await sink.abort();
328 throw e;
329 }
330 }
331
332 /** Keep the object alive for work that finishes after the current request. */
333 background(p: Promise<unknown>): void {
334 this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e)));
335 }
336
337 /**
338 * Set (or with null, clear) the object's single alarm. The merge queue uses it for check timeouts;
339 * while jobs wait on the account's hosted-job quota it also fires every minute to retry them, and it
340 * fires at the earliest running job's deadline (Actions.enforceJobDeadlines).
341 */
342 armAlarm(at: number | null): void {
343 const next = this.earliest(at, this.actions.hasAccountHeldJobs() ? Date.now() + HELD_RETRY_MS : null, this.actions.nextJobDeadline(), this.actions.nextStuckRunCheck(), this.actions.nextImageRetry());
344 this.ctx.waitUntil(next === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(next));
345 }
346
347 /** Make sure the alarm fires by `at`, keeping an earlier one. */
348 alarmBy(at: number): void {
349 this.ctx.waitUntil(
350 (async () => {
351 const current = await this.ctx.storage.getAlarm();
352 if (current === null || current > at) await this.ctx.storage.setAlarm(at);
353 })(),
354 );
355 }
356
357 private earliest(...times: Array<number | null>): number | null {
358 const set = times.filter((t): t is number => t !== null);
359 return set.length ? Math.min(...set) : null;
360 }
361
362 override async alarm(): Promise<void> {
363 const purging = await this.ctx.storage.get<string>(PURGE_KEY);
364 if (purging) return this.purge(purging);
365 await this.queue.process();
366 if (this.actions.hasAccountHeldJobs()) await this.actions.releaseAccountHeld();
367 await this.actions.relaunchImageHeld();
368 await this.actions.enforceJobDeadlines();
369 await this.actions.repairStuckRuns();
370 // Wake up again for held jobs, rollout waits, running jobs' deadlines and stuck runs, keeping the queue's own alarm if it set an earlier one.
371 const want = this.earliest(this.actions.hasAccountHeldJobs() ? Date.now() + HELD_RETRY_MS : null, this.actions.nextJobDeadline(), this.actions.nextStuckRunCheck(), this.actions.nextImageRetry());
372 if (want !== null) {
373 const current = await this.ctx.storage.getAlarm();
374 if (current === null || current <= Date.now() || current > want) await this.ctx.storage.setAlarm(want);
375 }
376 }
377
378 /** Queue an event (webhooks, notifications) and show it live to open pages; `live: false` skips the pages (e.g. draft releases, which readers can't see). */
379 emit(event: DiffineryEvent, opts?: { live?: boolean }): void {
380 this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e)));
381 // Open pages only learn that something changed and fetch it with the viewer's own access: the event
382 // itself (an audit entry's detail, a dispatch's payload) is not every reader's to see.
383 if (opts?.live !== false && event.type !== "audit") this.broadcast("repo", { kind: "event", type: event.type });
384 }
385
386 // ---------------------------------------------------------------- purging unreachable objects
387
388 /** Bumped by every purge; a push that started before one refuses its ref updates. */
389 private purgeEpoch = 0;
390
391 /**
392 * Delete every object no ref reaches, so a secret force-pushed away can't be fetched by its SHA any
393 * more (git keeps them until a gc, and so did Diffinery, forever). Also kept: the commits of open
394 * pull requests, of the merge queue and of unfinished workflow runs. Runs with every other request
395 * held, so nothing writes a ref meanwhile.
396 */
397 async purgeUnreachable(caller: Caller): Promise<{ objects: number; bytes: number }> {
398 if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can purge unreachable objects");
399 this.checkPurgeSize();
400 return this.ctx.blockConcurrencyWhile(() => this.purgeHeld());
401 }
402
403 /**
404 * Start over with fresh history: delete every closed and merged pull request (its conversation,
405 * reviews, review comments, events, reactions and refs/pull/<n>/* refs), then purge what nothing
406 * reaches any more. Without it, refs/pull keeps every old commit fetchable after the branches were
407 * rewritten. Issues, open pull requests (and those in the merge queue), branches, tags and workflow
408 * runs stay, and numbers are never reused. Runs with every other request held, like a purge.
409 */
410 async deleteClosedPullRequests(caller: Caller): Promise<{ pullRequests: number; objects: number; bytes: number }> {
411 if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can delete closed pull requests");
412 this.checkPurgeSize();
413 return this.ctx.blockConcurrencyWhile(async () => {
414 const numbers = this.sql
415 .exec<{ number: number }>("SELECT number FROM issues WHERE kind = 'pull' AND state = 'closed' AND number NOT IN (SELECT number FROM merge_queue) ORDER BY number")
416 .toArray()
417 .map((r) => r.number);
418 for (const n of numbers) this.forgetPull(n);
419 const purged = await this.purgeHeld();
420 return { pullRequests: numbers.length, ...purged };
421 });
422 }
423
424 /** Every row and ref a pull request left behind. */
425 private forgetPull(n: number): void {
426 const comments = this.sql.exec<{ id: string; review_id: string | null }>("SELECT id, review_id FROM comments WHERE number = ?", n).toArray();
427 for (const c of comments) this.sql.exec("DELETE FROM reactions WHERE subject = ?", `${c.review_id ? "review_comment" : "comment"}:${c.id}`);
428 this.sql.exec("DELETE FROM reactions WHERE subject = ?", `issue:${n}`);
429 for (const table of ["comments", "reviews", "issue_events", "issue_milestones", "pull_merge_state", "pulls", "issues"]) this.sql.exec(`DELETE FROM ${table} WHERE number = ?`, n);
430 this.sql.exec("DELETE FROM refs WHERE name >= ? AND name < ?", `refs/pull/${n}/`, `refs/pull/${n}/￿`);
431 }
432
433 private checkPurgeSize(): void {
434 const total = this.store.count();
435 if (total > MAX_PURGE_OBJECTS) throw new HttpError(422, `this repository has ${total} objects; purging works for up to ${MAX_PURGE_OBJECTS}. Ask a site administrator`);
436 }
437
438 /** The purge itself; the caller holds every other request (blockConcurrencyWhile). */
439 private async purgeHeld(): Promise<{ objects: number; bytes: number }> {
440 const roots = [
441 ...this.sql.exec<{ oid: string }>("SELECT oid FROM refs").toArray().map((r) => r.oid),
442 ...this.sql.exec<{ oid: string }>("SELECT head_sha AS oid FROM pulls UNION SELECT head_sha FROM merge_queue UNION SELECT group_sha FROM merge_queue WHERE group_sha IS NOT NULL UNION SELECT parent_sha FROM merge_queue WHERE parent_sha IS NOT NULL").toArray().map((r) => r.oid),
443 ...this.sql.exec<{ oid: string }>("SELECT sha AS oid FROM runs WHERE status != 'completed' UNION SELECT head_sha FROM runs WHERE status != 'completed' AND head_sha IS NOT NULL").toArray().map((r) => r.oid),
444 ];
445 const keep = new Set<string>();
446 const stack = [...roots];
447 while (stack.length) {
448 const oid = stack.pop()!;
449 if (keep.has(oid)) continue;
450 const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
451 if (!row) continue;
452 keep.add(oid);
453 const type = CODE_TYPES[row.type];
454 if (type === "blob") continue;
455 const obj = await this.store.read(oid);
456 if (!obj) continue;
457 if (type === "commit") {
458 const c = parseCommit(obj.content);
459 stack.push(c.tree, ...c.parents);
460 } else if (type === "tree") {
461 // A gitlink (160000) names a commit in another repository.
462 for (const e of parseTree(obj.content)) if (e.mode !== "160000") stack.push(e.oid);
463 } else if (type === "tag") {
464 const target = /^object ([0-9a-f]{40})/.exec(fromUtf8(obj.content))?.[1];
465 if (target) stack.push(target);
466 }
467 }
468 const doomed = this.sql.exec<{ oid: string; r2: number; size: number }>("SELECT oid, r2, size FROM objects").toArray().filter((r) => !keep.has(r.oid));
469 for (let i = 0; i < doomed.length; i += 100) {
470 const chunk = doomed.slice(i, i + 100).map((r) => r.oid);
471 this.sql.exec(`DELETE FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk);
472 }
473 this.store.forget();
474 this.store.recount();
475 this.storageChanged();
476 this.purgeEpoch++;
477 const r2 = doomed.filter((r) => r.r2).map((r) => this.store.r2KeyOf(r.oid));
478 for (let i = 0; i < r2.length; i += 1000) await this.env.BUCKET.delete(r2.slice(i, i + 1000));
479 // Cached zipballs and tarballs are rebuilt from what's left on the next download.
480 await deletePrefixes(this.env.BUCKET, [archivePrefix(this.meta.repoId)], 50);
481 return { objects: doomed.length, bytes: doomed.reduce((n, r) => n + r.size, 0) };
482 }
483
484 // ---------------------------------------------------------------- storage (the owner's storage quota, lib/quotas.ts)
485
486 /** The report to D1 waiting to run, and the last one started: reports run one at a time. */
487 private storageNext: Promise<void> | null = null;
488 private storageLast: Promise<void> = Promise.resolve();
489
490 /** Bytes the repository stores for its owner: git objects, Git LFS objects and release assets. */
491 async storageBytes(): Promise<number> {
492 await this.listLfs();
493 const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n;
494 return this.store.sizeBytes() + q("SELECT coalesce(sum(size), 0) AS n FROM lfs_objects") + q("SELECT coalesce(sum(size), 0) AS n FROM release_assets");
495 }
496
497 /**
498 * Tell D1 what the repository stores now (repo_storage), where the owner's quota adds it up. Calls
499 * made while a report waits to run share it; one made while a report is under way gets the next.
500 */
501 reportStorage(): Promise<void> {
502 this.storageNext ??= this.storageLast.then(async () => {
503 this.storageNext = null;
504 const bytes = await this.storageBytes();
505 await this.env.DB.prepare("INSERT INTO repo_storage (repo_id, bytes, updated_at) VALUES (?, ?, ?) ON CONFLICT(repo_id) DO UPDATE SET bytes = excluded.bytes, updated_at = excluded.updated_at")
506 .bind(this.meta.repoId, bytes, now())
507 .run();
508 });
509 const next = this.storageNext;
510 this.storageLast = next.catch(() => {});
511 return next;
512 }
513
514 /** What the repository stores changed: report it in the background. */
515 storageChanged(): void {
516 this.background(this.reportStorage());
517 }
518
519 /** Why storing `adding` more bytes here must be refused (past the owner's storage quota), or null. */
520 async storageRefusal(adding = 0): Promise<string | null> {
521 const m = this.meta;
522 return storageRefusal(this.env, m.ownerId, m.owner, adding, { repoId: m.repoId, bytes: await this.storageBytes() });
523 }
524
525 /** Refuse storing `adding` more bytes past the owner's storage quota. */
526 async checkStorage(adding = 0): Promise<void> {
527 const refused = await this.storageRefusal(adding);
528 if (refused) throw new HttpError(403, refused);
529 }
530
531 /** A Git LFS object was stored in R2 (http/git.ts). */
532 recordLfs(oid: string, size: number): void {
533 if (this.sql.exec("INSERT OR IGNORE INTO lfs_objects (oid, size) VALUES (?, ?) RETURNING oid", oid, size).toArray().length) this.storageChanged();
534 }
535
536 /** Git LFS objects stored before lfs_objects existed: listed from R2, once. */
537 private async listLfs(): Promise<void> {
538 if (this.sql.exec("SELECT 1 FROM counters WHERE name = 'lfs_listed'").toArray().length) return;
539 let cursor: string | undefined;
540 do {
541 const page = await this.env.BUCKET.list({ prefix: `repos/${this.meta.repoId}/lfs/`, cursor, limit: 1000 });
542 for (const o of page.objects) this.sql.exec("INSERT OR IGNORE INTO lfs_objects (oid, size) VALUES (?, ?)", o.key.slice(o.key.lastIndexOf("/") + 1), o.size);
543 cursor = page.truncated ? page.cursor : undefined;
544 } while (cursor);
545 this.sql.exec("INSERT OR IGNORE INTO counters (name, value) VALUES ('lfs_listed', 1)");
546 }
547
548 // ---------------------------------------------------------------- refs
549
550 refs(prefix = "refs/"): RefEntry[] {
551 return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}￿`).toArray();
552 }
553
554 getRef(name: string): string | null {
555 return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null;
556 }
557
558 /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */
559 casRef(name: string, expected: string | null, next: string | null): boolean {
560 const current = this.getRef(name);
561 if (expected !== null && (current ?? ZERO_OID) !== expected) return false;
562 if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name);
563 else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next);
564 return true;
565 }
566
567 get headRef(): string {
568 return `refs/heads/${this.meta.defaultBranch}`;
569 }
570
571 /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */
572 async resolve(rev: string | undefined | null): Promise<string | null> {
573 const r = rev && rev.length ? rev : "HEAD";
574 let oid: string | null = null;
575 if (r === "HEAD") oid = this.getRef(this.headRef);
576 else if (isOid(r)) oid = this.store.has(r) ? r : null;
577 else if (r.startsWith("refs/")) oid = this.getRef(r);
578 else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`);
579 if (!oid && /^[0-9a-f]{7,39}$/.test(r)) {
580 const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray();
581 if (rows.length === 1) oid = rows[0]!.oid;
582 }
583 if (!oid) return null;
584 return peelToCommit(this.store, oid);
585 }
586
587 async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> {
588 return this.refs("refs/heads/").map((r) => {
589 const name = r.name.slice(11);
590 return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch };
591 });
592 }
593
594 async tags(): Promise<Array<{ name: string; sha: string; target: string }>> {
595 const out = [];
596 for (const r of this.refs("refs/tags/")) out.push({ name: r.name.slice(10), sha: r.oid, target: await peelToCommit(this.store, r.oid).catch(() => r.oid) });
597 return out;
598 }
599
600 // ---------------------------------------------------------------- git smart HTTP
601
602 async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> {
603 if (service === "git-upload-pack") return advertiseUploadPackV2();
604 // Like GitHub, pull request refs are hidden from pushes (so `git push --mirror` leaves them alone).
605 return advertiseReceivePack(this.refs().filter((r) => !r.name.startsWith("refs/pull/")));
606 }
607
608 /** The response streams: the pack is read and sent one object at a time, as the client takes it. */
609 async gitUploadPack(body: Uint8Array): Promise<ReadableStream<Uint8Array>> {
610 const { readable, writable } = new IdentityTransformStream();
611 const writer = writable.getWriter();
612 let started = false;
613 const sink = (b: Uint8Array) => ((started = true), writer.write(b));
614 const ctx = { store: this.store, refs: this.refs(), headTarget: this.headRef };
615 void handleUploadPackV2(body, ctx, sink)
616 .catch((e) => (started ? undefined : writer.write(errorPacket((e as Error).message))))
617 .then(
618 () => writer.close(),
619 () => writer.abort(),
620 )
621 .catch(() => {}); // the client went away
622 return readable;
623 }
624
625 /**
626 * `moved`: the push came in under an old name (a transfer or rename), so say where the repository
627 * lives now, like GitHub. `retry`: the Worker hands over a push a deploy cut off (http/git.ts
628 * receiveWithRetry), which may already have moved some refs. A body staged in R2 is the Worker's
629 * to delete: deleted here, a push cut off after this point would find it gone on its retry.
630 */
631 async gitReceivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean; retry?: boolean } = {}): Promise<Uint8Array> {
632 const size = input instanceof Uint8Array ? input.length : input.size;
633 const held = size <= SMALL_PUSH_BYTES ? SMALL_PUSH_HELD : MAX_HELD_BYTES;
634 // A large body that has to wait goes to R2 meanwhile, so pushes waiting their turn don't hold their bodies.
635 let staged: string | null = null;
636 if (input instanceof Uint8Array && size > SMALL_PUSH_BYTES && !this.pushMemory.fits(size + held)) {
637 staged = pushBodyKey(this.meta.repoId);
638 await this.env.BUCKET.put(staged, input);
639 input = { spilled: staged, size };
640 }
641 const body = input instanceof Uint8Array ? size : 0;
642 const lease = await this.pushMemory.take(body + held);
643 try {
644 return await this.receivePack(input, caller, o, { held, grow: () => lease.grow(body + MAX_HELD_BYTES) });
645 } finally {
646 lease.release();
647 if (staged) await this.env.BUCKET.delete(staged).catch(() => {});
648 }
649 }
650
651 /** What pushes in flight here hold while they unpack (see PUSH_MEMORY_BYTES). */
652 private readonly pushMemory = new MemoryPool(PUSH_MEMORY_BYTES);
653
654 /** `memory`: what this push may hold while it unpacks, and how to wait for a full share when a small push needs more. */
655 private async receivePack(input: Uint8Array | SpilledBody, caller: Caller, o: { moved?: boolean; retry?: boolean }, memory: { held: number; grow: () => Promise<void> }): Promise<Uint8Array> {
656 const size = input instanceof Uint8Array ? input.length : input.size;
657 if (size > MAX_PUSH_BYTES) {
658 const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null };
659 return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []);
660 }
661 // A push the Worker staged in R2 (over 16 MB) is read in ranges, never whole: its objects are
662 // unpacked one at a time (git/push-contents.ts), so its size doesn't decide the memory it takes.
663 let req: ReceiveRequest;
664 let packAt = 0;
665 if (input instanceof Uint8Array) req = parseReceivePack(input);
666 else {
667 try {
668 const head = await readStagedHead(this.env.BUCKET, input.spilled, input.size);
669 req = head.req;
670 packAt = head.packOffset;
671 } catch (e) {
672 // A deploy that stops this object mid-push is the Worker's to retry (http/git.ts receiveWithRetry).
673 if (resetByDeploy(e)) throw e;
674 return reportStatus({ commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }, unpackFailure(e), []);
675 }
676 }
677 // A purge of unreachable objects that ran while this push was in flight may have deleted objects
678 // the push counted on the repository having: refuse rather than point a ref at them.
679 const epoch = this.purgeEpoch;
680 // `git push -o diffinery-import` from a GitHub import's own token: no workflows, webhooks,
681 // pull request syncs or notifications (see apps/diffinery/src/imports).
682 const importMode = !!caller.importId && req.pushOptions.some((o) => o.trim() === "diffinery-import");
683 // H2: while an import holds the repository, only that import may push. Everything else waits
684 // until the import finalises, so protection, guardrails, CI and webhooks are never bypassed.
685 const lockHolder = this.importLockHolder();
686 if (lockHolder && (!importMode || caller.importId !== lockHolder)) {
687 const denied = req.commands.map((c) => ({ ref: c.ref, ok: false as const, reason: "repository is locked by an import in progress" }));
688 return reportStatus(req, "repository is locked by an import in progress", denied);
689 }
690 const messages: string[] = o.moved ? ["This repository moved. Please use the new location:", ` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}.git`] : [];
691 let unpackError: string | null = null;
692 // Like git's quarantine: nothing from the pack is stored until push protection passes,
693 // so a blocked push leaves no objects behind for a retry to build on.
694 let contents: PushContents = packedContents(this.store, [], 0);
695 const unpack = async (maxHeldBytes: number): Promise<PushContents> => {
696 const opts = { largeObjectBytes: LARGE_OBJECT_BYTES, maxHeldBytes };
697 if (!(input instanceof Uint8Array)) return packAt < input.size ? stagedContents(this.store, this.env.BUCKET, input.spilled, packAt, input.size - packAt, opts) : contents;
698 const pack = req.pack;
699 if (!pack?.length) return contents;
700 return parsePack(pack, repositoryBases(this.store), opts).then(
701 (objects) => packedContents(this.store, objects, pack.length),
702 // Parsed whole, a pack holds every object at once: one that can't, such as a long history
703 // of a large file, is unpacked in pieces instead, holding only the objects in use.
704 (e: unknown) => {
705 if (!(e instanceof TooMuchMemory)) throw e;
706 return streamedContents(this.store, memorySource(pack), opts);
707 },
708 );
709 };
710 try {
711 try {
712 contents = await unpack(memory.held);
713 } catch (e) {
714 // A small push that needs more than its small share waits for a full one.
715 if (!(e instanceof TooMuchMemory) || memory.held >= MAX_HELD_BYTES) throw e;
716 await memory.grow();
717 contents = await unpack(MAX_HELD_BYTES);
718 }
719 } catch (e) {
720 if (resetByDeploy(e)) throw e;
721 unpackError = unpackFailure(e);
722 }
723 const results: RefResult[] = [];
724 if (unpackError) {
725 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
726 return reportStatus(req, unpackError, results);
727 }
728 // Like GitHub's receive.fsckObjects: no tree a git client would refuse, or that writes outside the work tree.
729 const bad = contents.fsck();
730 if (bad) {
731 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "fsck failed" });
732 return reportStatus(req, `fsck error: ${bad}`, results, [`Push refused: ${bad}`]);
733 }
734
735 const marked: SecretHit[] = [];
736 // Commit and tag messages are scanned too, not only files.
737 const leaks = contents.scan(marked);
738 // A file can opt out with `diffinery:allow-secret`; that it did is on the record.
739 if (marked.length) this.emit({ type: "audit", action: "push.secret_marked", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: [...new Set(marked.map((l) => l.kind))], blobs: marked.slice(0, 50).map((l) => l.oid), refs: req.commands.map((c) => c.ref) }, at: now() });
740 if (leaks.length) {
741 const kinds = leaks.map((l) => l.kind);
742 const target = `${this.meta.owner}/${this.meta.name}`;
743 // Like GitHub's push protection bypass: a human repository admin may push anyway by
744 // giving a reason (`git push -o allow-secrets=<reason>`), and every bypass is audited.
745 const reason = secretBypassReason(req.pushOptions);
746 if (reason && caller.role === "admin" && caller.kind === "user") {
747 messages.push(`Push protection bypassed by ${caller.login}: ${reason}`);
748 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
749 this.emit({ type: "audit", action: "push.secret_bypassed", actor: caller, target, detail: { kinds, reason, refs: req.commands.map((c) => c.ref), ...(importMode ? { import: caller.importId, blobs: leaks.slice(0, 50).map((l) => l.oid) } : {}) }, at: now() });
750 if (importMode) this.importsModule.recordFindings(leaks);
751 } else {
752 messages.push("Push blocked: possible secrets detected.");
753 for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`);
754 messages.push("Remove the secret from your commits, or rotate it and contact an admin.");
755 if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection.");
756 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" });
757 this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds, refs: req.commands.map((c) => c.ref) }, at: now() });
758 return reportStatus(req, null, results, messages);
759 }
760 }
761
762 // Past the owner's storage quota nothing new is stored; a push that only deletes or moves refs still works.
763 const full = contents.anyNew() ? await this.storageRefusal(contents.bytes) : null;
764 if (full) {
765 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "storage limit reached" });
766 return reportStatus(req, null, results, [...messages, `Push refused: ${full}`]);
767 }
768
769 try {
770 await contents.write();
771 } catch (e) {
772 if (resetByDeploy(e)) throw e;
773 for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" });
774 return reportStatus(req, unpackFailure(e), results);
775 }
776
777 // Validate every command before applying any, so `atomic` is honoured.
778 const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = [];
779 for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller, importMode)) });
780 const atomic = req.capabilities.has("atomic");
781 const anyFailed = checks.some((c) => !c.ok);
782 let imported = 0;
783 for (const c of checks) {
784 if (!c.ok) {
785 results.push({ ref: c.ref, ok: false, reason: c.reason });
786 continue;
787 }
788 if (atomic && anyFailed) {
789 results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" });
790 continue;
791 }
792 if (epoch !== this.purgeEpoch) {
793 results.push({ ref: c.ref, ok: false, reason: "the repository's unreachable objects were purged during this push; push again" });
794 continue;
795 }
796 // On a retry, a ref the cut-off attempt already moved is where the push wants it: that is a
797 // success, not stale info. Its push event goes out (again), as the cut-off attempt may have
798 // stopped before sending it: a missing one would leave a pull request without its checks.
799 const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid) || (!!o.retry && (this.getRef(c.cmd.ref) ?? ZERO_OID) === c.cmd.newOid);
800 results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" });
801 if (applied && importMode) {
802 imported++;
803 // Record provenance so a later push may move this ref only if the import still owns it.
804 if (c.cmd.newOid === ZERO_OID) this.sql.exec("DELETE FROM import_refs WHERE ref = ?", c.cmd.ref);
805 else this.importsModule.recordImportRef(c.cmd.ref, c.cmd.newOid, caller.importId!);
806 } else if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages);
807 }
808 // One audit entry per import push instead of push events.
809 if (imported) this.emit({ type: "audit", action: "import.push", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { import: caller.importId, refs: imported }, at: now() });
810 return reportStatus(req, null, results, messages);
811 }
812
813 /** As on GitHub: a rule naming the branch exactly wins; otherwise the oldest matching pattern. */
814 protectionFor(branch: string): ProtectionRules | null {
815 return this.protectionRuleFor(branch)?.rules ?? null;
816 }
817
818 /** The rule that applies to `branch`, with its pattern: an exact name first, else the first matching pattern. */
819 protectionRuleFor(branch: string): { pattern: string; rules: ProtectionRules } | null {
820 const rows = this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY rowid").toArray();
821 const row = rows.find((r) => r.pattern === branch) ?? rows.find((r) => globToRegExp(r.pattern).test(branch));
822 return row ? { pattern: row.pattern, rules: JSON.parse(row.rules) as ProtectionRules } : null;
823 }
824
825 /**
826 * Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails.
827 * `importMode`: a GitHub import filling the repository it created, which is the only writer
828 * until it finishes, so branch rules (copied from GitHub at its end) don't apply to it.
829 */
830 async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, importMode = false): Promise<RefResult> {
831 if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" };
832 if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" };
833 const deleting = newOid === ZERO_OID;
834 if (ref.startsWith("refs/pull/")) return { ref, ok: false, reason: "refs/pull/ is managed by Diffinery for pull requests" };
835 if (ref.startsWith(`refs/heads/${QUEUE_BRANCH_PREFIX}`)) return { ref, ok: false, reason: "merge queue branches are read-only" };
836 if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" };
837 if (ref.startsWith(IMPORT_REF_PREFIX) && !importMode) return { ref, ok: false, reason: `${IMPORT_REF_PREFIX} is reserved for GitHub imports` };
838 if (importMode) {
839 if (!/^refs\/(heads|tags|diffinery-import)\//.test(ref)) return { ref, ok: false, reason: "imports write branches and tags only" };
840 // H2: create a ref, or move one this import previously wrote — never a ref adopted since.
841 const current = this.getRef(ref);
842 if (current && !deleting) {
843 const owned = this.importsModule.importRefOid(ref, caller.importId!);
844 if (owned !== current) return { ref, ok: false, reason: "import may not overwrite a ref it did not write" };
845 }
846 return { ref, ok: true };
847 }
848 // A workflow's GITHUB_TOKEN never adds or changes workflow files (GitHub's `workflows` permission).
849 const workflowEdit = await this.policy.refuseWorkflowEdit(caller, oldOid, newOid);
850 if (workflowEdit) return { ref, ok: false, reason: workflowEdit };
851 if (!ref.startsWith("refs/heads/")) {
852 if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" };
853 return { ref, ok: true };
854 }
855 const branch = ref.slice(11);
856 const rules = this.protectionFor(branch);
857 const isDefault = branch === this.meta.defaultBranch;
858 // An empty repository's first commit: an agent that created a repository (or was given an empty one) may
859 // start its default branch, as GitHub's auto_init would; from then on it works through pull requests.
860 // A protection rule covering the branch still applies, and the commit may not add workflow files: those reach
861 // the default branch only through a pull request a person merges, as with auto-merge.
862 const seeding = caller.kind === "agent" && isDefault && !rules && oldOid === ZERO_OID && !this.sql.exec("SELECT 1 FROM refs WHERE name LIKE 'refs/heads/%' LIMIT 1").toArray().length;
863 if (seeding) {
864 const workflow = await this.policy.changedWorkflow(ZERO_OID, newOid);
865 if (workflow) return { ref, ok: false, reason: `an agent's first commit to an empty repository can't add workflow file \`${workflow}\`; push it without workflows, then add them in a pull request a person merges` };
866 }
867 if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush && !seeding) {
868 return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" };
869 }
870 if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" };
871 if (!rules) return { ref, ok: true };
872 const bypass = caller.role === "admin" && !rules.enforceAdmins;
873 if (bypass) return { ref, ok: true };
874 if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" };
875 if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" };
876 // As on GitHub, required checks gate direct pushes too: the commit must already have passed them.
877 for (const name of rules.requiredChecks ?? []) {
878 const c = requiredCheck(name, [...this.collab.checks(newOid), ...this.statuses.asChecks(newOid)], rules.requiredCheckSources?.[name]);
879 if (c.state !== "success") {
880 const why = c.state === "missing" ? "has not run" : c.state === "pending" ? "has not finished" : `ended with ${c.conclusion}`;
881 return { ref, ok: false, reason: `protected branch: required check '${name}' ${why} on ${newOid.slice(0, 7)}; push to another branch and open a pull request` };
882 }
883 }
884 if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) {
885 return { ref, ok: false, reason: "protected branch: force push not allowed" };
886 }
887 if (rules.requireLinearHistory) {
888 const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 });
889 if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" };
890 }
891 return { ref, ok: true };
892 }
893
894 /** Side effects after a ref moved: events, PR sync, workflow triggers. */
895 async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> {
896 const at = now();
897 const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid));
898 const ev: DiffineryEvent = {
899 type: "push",
900 repoId: this.meta.repoId,
901 owner: this.meta.owner,
902 repo: this.meta.name,
903 ref,
904 before: oldOid,
905 after: newOid,
906 actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf },
907 forced,
908 created: oldOid === ZERO_OID,
909 deleted: newOid === ZERO_OID,
910 at,
911 };
912 this.emit(ev);
913 this.policy.recordPush(ref, newOid, caller);
914 await this.policy.trackAgentCommits(ref, oldOid, newOid, caller).catch((e) => console.error("could not record an agent's commits", ref, e));
915 this.deploys.noteRefUpdate(ref, newOid, caller);
916 if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) {
917 const branch = ref.slice(11);
918 const synced = await this.collab.syncPullsForBranch(branch, newOid, caller);
919 for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`);
920 if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) {
921 messages.push(`Create a pull request for '${branch}':`);
922 messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`);
923 }
924 }
925 if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`));
926 // A base branch that moved (outside the queue, or by it) needs its merge groups checked,
927 // and pull requests into it waiting to auto-merge may now be up to date.
928 if (ref.startsWith("refs/heads/") && this.queue.hasEntries(ref.slice(11))) this.queue.schedule(ref.slice(11));
929 if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) this.autoMerge.schedule({ base: ref.slice(11) });
930 }
931
932 // ---------------------------------------------------------------- browsing
933
934 async commitInfo(sha: string): Promise<CommitInfo> {
935 const c = await readCommit(this.store, sha);
936 return toInfo(sha, c);
937 }
938
939 async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> {
940 const sha = await this.resolve(rev);
941 if (!sha) return null;
942 const c = await readCommit(this.store, sha);
943 const entry = await resolvePath(this.store, c.tree, path);
944 if (!entry || !isTreeMode(entry.mode)) return null;
945 const items: TreeItem[] = [];
946 for (const e of await readTree(this.store, entry.oid)) {
947 const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob";
948 const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined;
949 items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size });
950 }
951 items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1));
952 return { sha, items };
953 }
954
955 async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> {
956 const sha = await this.resolve(rev);
957 if (!sha) return null;
958 const c = await readCommit(this.store, sha);
959 const entry = await resolvePath(this.store, c.tree, path);
960 if (!entry || isTreeMode(entry.mode)) return null;
961 const o = await this.store.read(entry.oid);
962 if (!o) return null;
963 return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content };
964 }
965
966 async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> {
967 const sha = await this.resolve(rev);
968 if (!sha) return [];
969 const limit = Math.min(opts.limit ?? 30, 100);
970 const skip = Math.max(opts.skip ?? 0, 0);
971 if (!opts.path) {
972 // Paging past MAX_LOG_DEPTH lists nothing rather than walking the whole history for one page.
973 if (skip >= MAX_LOG_DEPTH) return [];
974 const commits = await walkCommits(this.store, [sha], { limit: Math.min(limit + skip, MAX_LOG_DEPTH) });
975 return commits.slice(skip).map((c) => toInfo(c.oid, c.commit));
976 }
977 // Path history: commits whose tree entry at `path` differs from their first parent's.
978 const out: CommitInfo[] = [];
979 let skipped = 0;
980 for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) {
981 const mine = await resolvePath(this.store, c.commit.tree, opts.path);
982 const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null;
983 const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null;
984 if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue;
985 if (skipped++ < skip) continue;
986 out.push(toInfo(c.oid, c.commit));
987 if (out.length >= limit) break;
988 }
989 return out;
990 }
991
992 /** Diff between two commits (or a commit and its first parent). */
993 async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) {
994 const head = await readCommit(this.store, headSha);
995 const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null;
996 const changes = await diffTrees(this.store, baseTree, head.tree);
997 const max = opts.maxFiles ?? 300;
998 const files = [];
999 const budget = new DiffBudget();
1000 for (const ch of changes.slice(0, max)) {
1001 const d = await fileDiff(this.store, ch, 3, budget);
1002 files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) });
1003 }
1004 return {
1005 files,
1006 totalFiles: changes.length,
1007 additions: files.reduce((s, f) => s + f.additions, 0),
1008 deletions: files.reduce((s, f) => s + f.deletions, 0),
1009 truncated: changes.length > max,
1010 };
1011 }
1012
1013 async commitWithDiff(rev: string) {
1014 const sha = await this.resolve(rev);
1015 if (!sha) throw new HttpError(404, "commit not found");
1016 const info = await this.commitInfo(sha);
1017 return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) };
1018 }
1019
1020 async compare(base: string, head: string) {
1021 const b = await this.resolve(base);
1022 const h = await this.resolve(head);
1023 if (!b || !h) throw new HttpError(404, "unknown base or head");
1024 const mb = await mergeBase(this.store, b, h);
1025 const commits = (await walkCommits(this.store, [h], { stop: new Set(mb ? (await walkCommits(this.store, [mb], { limit: 100000 })).map((c) => c.oid) : []), limit: 250 })).map((c) => toInfo(c.oid, c.commit));
1026 const ahead = commits.length;
1027 const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0;
1028 return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) };
1029 }
1030
1031 /**
1032 * Push protection for commits made without a Git client: the same scan as `git push`, so the
1033 * web editor, the contents and commits APIs and MCP cannot be used to slip a credential in.
1034 */
1035 private protectCommit(changes: readonly FileChange[], allowSecrets: string | undefined, caller: Caller, message = ""): void {
1036 const blobs = changes.filter((ch) => !ch.delete && ch.content).map((ch) => ({ oid: ch.path, content: ch.content! }));
1037 if (message) blobs.push({ oid: "(commit message)", content: utf8(message) });
1038 const marked: SecretHit[] = [];
1039 const leaks = scanForSecrets(blobs, marked);
1040 const target = `${this.meta.owner}/${this.meta.name}`;
1041 if (marked.length) this.emit({ type: "audit", action: "api.secret_marked", actor: caller, target, detail: { kinds: [...new Set(marked.map((l) => l.kind))], paths: [...new Set(marked.map((l) => l.oid))].slice(0, 10) }, at: now() });
1042 if (!leaks.length) return;
1043 const kinds = [...new Set(leaks.map((l) => l.kind))];
1044 const paths = [...new Set(leaks.map((l) => l.oid))].slice(0, 10);
1045 const reason = allowSecrets?.trim().slice(0, 200);
1046 if (reason && caller.role === "admin" && caller.kind === "user") {
1047 this.emit({ type: "audit", action: "api.secret_bypassed", actor: caller, target, detail: { kinds, paths, reason }, at: now() });
1048 return;
1049 }
1050 this.emit({ type: "audit", action: "api.blocked_secret", actor: caller, target, detail: { kinds, paths }, at: now() });
1051 const hint = reason ? " Only repository admins (people, not agents) can bypass push protection." : "";
1052 throw new HttpError(422, `push protection: possible secret detected (${kinds.join(", ")}) in ${paths.join(", ")}. Remove it, or rotate it and ask an admin.${hint}`);
1053 }
1054
1055 /**
1056 * Create a commit from file changes without a Git client (used by the web
1057 * editor, the REST contents API and AI agents through MCP).
1058 */
1059 async commitFiles(input: {
1060 branch: string;
1061 message: string;
1062 changes: FileChange[];
1063 expectedHeadSha?: string;
1064 createBranchFrom?: string;
1065 author?: { name: string; email: string };
1066 committer?: { name: string; email: string };
1067 /** A human admin's reason for committing a detected secret anyway (like `git push -o allow-secrets=`). */
1068 allowSecrets?: string;
1069 }, caller: Caller): Promise<{ sha: string; branch: string }> {
1070 const ref = `refs/heads/${input.branch}`;
1071 let parent = this.getRef(ref);
1072 if (!parent) {
1073 if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom);
1074 else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`);
1075 }
1076 if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent });
1077 for (const ch of input.changes) {
1078 const why = ch.delete ? null : unsafeTreePath(ch.path);
1079 if (why) throw new HttpError(422, `invalid path ${JSON.stringify(ch.path)}: ${why}`);
1080 }
1081 const adding = input.changes.reduce((n, ch) => n + (ch.content?.length ?? 0), 0);
1082 if (adding) await this.checkStorage(adding);
1083 this.protectCommit(input.changes, input.allowSecrets, caller, input.message);
1084 const baseTree = parent ? (await readCommit(this.store, parent)).tree : null;
1085 const tree = await applyChanges(this.store, baseTree, input.changes);
1086 const ts = Math.floor(Date.now() / 1000);
1087 const committer = input.committer
1088 ? { ...input.committer, timestamp: ts, timezone: "+0000" }
1089 : { name: caller.login, email: this.noreply(caller.login), timestamp: ts, timezone: "+0000" };
1090 const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer;
1091 const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : [];
1092 const sha = await this.store.write(
1093 "commit",
1094 serializeCommit({ tree: tree === EMPTY_TREE_OID ? tree : tree, parents: parent ? [parent] : [], author, committer, message: input.message.endsWith("\n") ? input.message : `${input.message}\n`, extraHeaders: extra }),
1095 );
1096 const old = parent && this.getRef(ref) ? parent : ZERO_OID;
1097 const check = await this.checkRefUpdate(ref, old, sha, caller);
1098 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
1099 if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry");
1100 await this.afterRefUpdate(ref, old, sha, caller);
1101 return { sha, branch: input.branch };
1102 }
1103
1104 async createRef(ref: string, sha: string, caller: Caller): Promise<void> {
1105 if (!this.store.has(sha)) throw new HttpError(422, "object does not exist");
1106 const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller);
1107 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
1108 if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists");
1109 await this.afterRefUpdate(ref, ZERO_OID, sha, caller);
1110 }
1111
1112 async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> {
1113 const old = this.getRef(ref);
1114 if (!old) throw new HttpError(404, "reference not found");
1115 if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward");
1116 const check = await this.checkRefUpdate(ref, old, sha, caller);
1117 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
1118 if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved");
1119 await this.afterRefUpdate(ref, old, sha, caller);
1120 }
1121
1122 async deleteRef(ref: string, caller: Caller): Promise<void> {
1123 const old = this.getRef(ref);
1124 if (!old) throw new HttpError(404, "reference not found");
1125 const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller);
1126 if (!check.ok) throw new HttpError(403, check.reason ?? "rejected");
1127 this.casRef(ref, old, null);
1128 await this.afterRefUpdate(ref, old, ZERO_OID, caller);
1129 }
1130
1131 // ---------------------------------------------------------------- protections
1132
1133 /**
1134 * What Settings → Merge on green (web/merge-on-green.tsx) suggests: the checks reported on the heads of the
1135 * latest pull requests, which are the names a required check must match, and the agents that opened pull
1136 * requests here. `latest`: the check ran on each of the last three pull requests that had checks, so
1137 * requiring it won't hold up a pull request whose workflows skip it. Reports by agents never count.
1138 */
1139 mergeOnGreenHints(): { pulls: number; checks: Array<{ name: string; pulls: number; latest: boolean }>; agents: Array<{ id: string; login: string }> } {
1140 const heads = this.sql.exec<{ head_sha: string }>("SELECT head_sha FROM pulls ORDER BY number DESC LIMIT 20").toArray();
1141 const count = new Map<string, number>();
1142 const recent: Array<Set<string>> = [];
1143 let pulls = 0;
1144 for (const h of heads) {
1145 const names = new Set([...this.collab.checks(h.head_sha), ...this.statuses.asChecks(h.head_sha)].filter((c) => c.creatorKind !== "agent").map((c) => c.name));
1146 if (!names.size) continue;
1147 pulls++;
1148 if (recent.length < 3) recent.push(names);
1149 for (const n of names) count.set(n, (count.get(n) ?? 0) + 1);
1150 }
1151 const checks = [...count]
1152 .map(([name, n]) => ({ name, pulls: n, latest: recent.every((r) => r.has(name)) }))
1153 .sort((a, b) => Number(b.latest) - Number(a.latest) || b.pulls - a.pulls || a.name.localeCompare(b.name))
1154 .slice(0, 50);
1155 const agents: Array<{ id: string; login: string }> = [];
1156 for (const a of this.sql.exec<{ author_id: string; author_login: string }>("SELECT author_id, author_login FROM issues WHERE kind = 'pull' AND author_kind = 'agent' ORDER BY number DESC LIMIT 200").toArray()) {
1157 if (agents.length >= 20) break;
1158 if (!agents.some((x) => x.id === a.author_id)) agents.push({ id: a.author_id, login: a.author_login });
1159 }
1160 return { pulls, checks, agents };
1161 }
1162
1163 listProtections(): Array<{ pattern: string; rules: ProtectionRules }> {
1164 return this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY pattern").toArray().map((r) => ({ pattern: r.pattern, rules: JSON.parse(r.rules) }));
1165 }
1166
1167 /**
1168 * Create, replace or (with null) delete a rule. Only people who administer the repository change
1169 * branch protection: never agents, never a workflow's token. Turning `allowAgentAutoMerge`,
1170 * `agentAutoMergeWithoutApproval` or `autoMergeAgents` on or off is audited on its own, since they let
1171 * agents land changes.
1172 */
1173 async setProtection(pattern: string, input: ProtectionRulesInput | null, caller: Caller): Promise<void> {
1174 if (!isPerson(caller) || caller.role !== "admin") throw new HttpError(403, caller.kind === "agent" ? "agents cannot change branch protection" : "only people who administer the repository can change branch protection");
1175 let rules = input ? await this.resolveAutoMergeAgents(input) : null;
1176 if (rules?.mergeQueue) {
1177 const asked = typeof rules.mergeQueue === "object" ? rules.mergeQueue.method : undefined;
1178 const mergeQueue = normalizeMergeQueue(rules.mergeQueue);
1179 // A queue that makes merge commits could never merge into a branch that refuses them.
1180 if (rules.requireLinearHistory && mergeQueue.method === "merge") {
1181 if (asked !== undefined) throw new HttpError(422, "this branch requires linear history, so its merge queue must squash or rebase (mergeQueue.method), not create merge commits");
1182 mergeQueue.method = "squash";
1183 }
1184 rules = { ...rules, mergeQueue };
1185 }
1186 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
1187 // A required "Agent review" means Diffinery's own check (agentic/review.ts), not one anyone with write access could post.
1188 if (rules?.requiredChecks?.includes(REVIEW_CHECK) && !rules.requiredCheckSources?.[REVIEW_CHECK]) rules = { ...rules, requiredCheckSources: { ...rules.requiredCheckSources, [REVIEW_CHECK]: REVIEW_APP } };
1189 if (rules && rules.allowAgentAutoMerge !== undefined && typeof rules.allowAgentAutoMerge !== "boolean") throw new HttpError(422, "allowAgentAutoMerge must be a boolean");
1190 if (rules && rules.agentAutoMergeWithoutApproval !== undefined && typeof rules.agentAutoMergeWithoutApproval !== "boolean") throw new HttpError(422, "agentAutoMergeWithoutApproval must be a boolean");
1191 if (rules?.agentAutoMergeWithoutApproval && !rules.allowAgentAutoMerge) throw new HttpError(422, "agentAutoMergeWithoutApproval needs allowAgentAutoMerge: it only applies to what Diffinery lands for agents");
1192 if (rules?.autoMergeAgents?.length && !rules.allowAgentAutoMerge) throw new HttpError(422, "autoMergeAgents needs allowAgentAutoMerge: Diffinery turns on auto-merge as those agents, under the same rules as their own");
1193 if (rules?.autoMergeMethod !== undefined && !(["merge", "squash", "rebase"] as const).includes(rules.autoMergeMethod)) throw new HttpError(422, "autoMergeMethod must be merge, squash or rebase");
1194 if (rules?.autoMergeMethod === "merge" && rules.requireLinearHistory) throw new HttpError(422, "this branch requires linear history, so autoMergeMethod must be squash or rebase");
1195 if (rules && !rules.allowAgentAutoMerge) {
1196 const { allowAgentAutoMerge: _, agentAutoMergeWithoutApproval: _w, autoMergeAgents: _a, autoMergeMethod: _m, ...rest } = rules;
1197 rules = rest;
1198 }
1199 if (rules && !rules.agentAutoMergeWithoutApproval) {
1200 const { agentAutoMergeWithoutApproval: _, ...rest } = rules;
1201 rules = rest;
1202 }
1203 if (rules && !rules.autoMergeAgents?.length) {
1204 const { autoMergeAgents: _, autoMergeMethod: _m, ...rest } = rules;
1205 rules = rest;
1206 }
1207 const before = this.sql.exec<{ rules: string }>("SELECT rules FROM protections WHERE pattern = ?", pattern).toArray()[0];
1208 const old = before ? (JSON.parse(before.rules) as ProtectionRules) : null;
1209 const was = !!old?.allowAgentAutoMerge;
1210 const waived = !!old?.agentAutoMergeWithoutApproval;
1211 const agentsKey = (r: ProtectionRules | null) => (r?.autoMergeAgents?.length ? `${r.autoMergeAgents.map((a) => a.id).join(",")}|${r.autoMergeMethod ?? ""}` : "");
1212 if (rules) this.sql.exec("INSERT INTO protections (pattern, rules) VALUES (?, ?) ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules", pattern, JSON.stringify(rules));
1213 else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern);
1214 if (was !== !!rules?.allowAgentAutoMerge) {
1215 const m = this.meta;
1216 this.emit({ type: "audit", action: "repo.protection.agent_auto_merge", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { pattern, enabled: !!rules?.allowAgentAutoMerge, requiredChecks: rules?.requiredChecks ?? [] }, at: now() });
1217 }
1218 if (waived !== !!rules?.agentAutoMergeWithoutApproval) {
1219 const m = this.meta;
1220 this.emit({ type: "audit", action: "repo.protection.agent_approval_waiver", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { pattern, enabled: !!rules?.agentAutoMergeWithoutApproval, requiredChecks: rules?.requiredChecks ?? [] }, at: now() });
1221 }
1222 if (agentsKey(old) !== agentsKey(rules)) {
1223 const m = this.meta;
1224 this.emit({
1225 type: "audit",
1226 action: "repo.protection.agent_auto_merge_rule",
1227 actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf },
1228 target: `${m.owner}/${m.name}`,
1229 detail: { pattern, enabled: !!rules?.autoMergeAgents?.length, agents: (rules?.autoMergeAgents ?? []).map((a) => a.login), method: rules?.autoMergeMethod ?? null, withoutApproval: !!rules?.agentAutoMergeWithoutApproval, requiredChecks: rules?.requiredChecks ?? [] },
1230 at: now(),
1231 });
1232 }
1233 // Turning a queue off (or changing its checks) affects what is queued, and what may auto-merge.
1234 this.queue.schedule();
1235 this.autoMerge.schedule();
1236 }
1237
1238 /** `autoMergeAgents` names agent accounts, by login or as stored ({ id, login }); store them by id, with the login as it is now. */
1239 private async resolveAutoMergeAgents(input: ProtectionRulesInput): Promise<ProtectionRules> {
1240 const { autoMergeAgents: names, ...rest } = input;
1241 if (names === undefined || names === null) return rest;
1242 if (!Array.isArray(names)) throw new HttpError(422, "autoMergeAgents must be a list of agent logins");
1243 if (names.length > MAX_AUTO_MERGE_AGENTS) throw new HttpError(422, `autoMergeAgents can name at most ${MAX_AUTO_MERGE_AGENTS} agents`);
1244 const dir = new Directory(this.env);
1245 const agents: AutoMergeAgent[] = [];
1246 for (const n of names) {
1247 const login = typeof n === "string" ? n.trim().replace(/^@/, "") : n && typeof n === "object" && typeof n.login === "string" ? n.login : null;
1248 if (!login) throw new HttpError(422, "autoMergeAgents must be a list of agent logins");
1249 const account = typeof n === "object" && typeof n.id === "string" ? ((await dir.account(n.id)) ?? (await dir.accountByLogin(login))) : await dir.accountByLogin(login);
1250 if (!account) throw new HttpError(422, `autoMergeAgents: there is no account named ${login}`);
1251 if (account.kind !== "agent") throw new HttpError(422, `autoMergeAgents: ${account.login} is not an agent; only agents' pull requests can be auto-merged by a rule`);
1252 if (!agents.some((a) => a.id === account.id)) agents.push({ id: account.id, login: account.login });
1253 }
1254 return agents.length ? { ...rest, autoMergeAgents: agents } : rest;
1255 }
1256
1257 // ---------------------------------------------------------------- RPC passthroughs (collab + actions)
1258 // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here.
1259
1260 issues(...a: Parameters<Collab["list"]>) {
1261 return this.collab.list(...a);
1262 }
1263 issue(...a: Parameters<Collab["get"]>) {
1264 return this.collab.get(...a);
1265 }
1266 createIssue(...a: Parameters<Collab["createIssue"]>) {
1267 return this.collab.createIssue(...a);
1268 }
1269 updateIssue(...a: Parameters<Collab["update"]>) {
1270 return this.collab.update(...a);
1271 }
1272 setPullDraft(...a: Parameters<Collab["setDraft"]>) {
1273 return this.collab.setDraft(...a);
1274 }
1275 addComment(...a: Parameters<Collab["addComment"]>) {
1276 return this.collab.addComment(...a);
1277 }
1278 editComment(...a: Parameters<Collab["editComment"]>) {
1279 return this.collab.editComment(...a);
1280 }
1281 minimizeComment(...a: Parameters<Collab["minimizeComment"]>) {
1282 return this.collab.minimizeComment(...a);
1283 }
1284 createPull(...a: Parameters<Collab["createPull"]>) {
1285 return this.collab.createPull(...a);
1286 }
1287 pullFiles(...a: Parameters<Collab["pullFiles"]>) {
1288 return this.collab.pullFiles(...a);
1289 }
1290 pullCommits(...a: Parameters<Collab["pullCommits"]>) {
1291 return this.collab.pullCommits(...a);
1292 }
1293 pullStats(...a: Parameters<Collab["pullStats"]>) {
1294 return this.collab.pullStats(...a);
1295 }
1296 reviewState(...a: Parameters<Collab["reviewState"]>) {
1297 return this.collab.reviewState(...a);
1298 }
1299 submitReview(...a: Parameters<Collab["submitReview"]>) {
1300 return this.collab.submitReview(...a);
1301 }
1302 resolveThread(...a: Parameters<Collab["resolveThread"]>) {
1303 return this.collab.resolveThread(...a);
1304 }
1305 mergeability(...a: Parameters<Collab["mergeability"]>) {
1306 return this.collab.mergeability(...a);
1307 }
1308 mergePull(...a: Parameters<Collab["merge"]>) {
1309 return this.collab.merge(...a);
1310 }
1311 requestReviewers(...a: Parameters<Collab["requestReviewers"]>) {
1312 return this.collab.requestReviewers(...a);
1313 }
1314 labels(...a: Parameters<Collab["labels"]>) {
1315 return this.collab.labels(...a);
1316 }
1317 setLabel(...a: Parameters<Collab["setLabel"]>) {
1318 return this.collab.setLabel(...a);
1319 }
1320 checks(...a: Parameters<Collab["checks"]>) {
1321 return this.collab.checks(...a);
1322 }
1323 upsertCheck(...a: Parameters<Collab["upsertCheck"]>) {
1324 return this.collab.upsertCheck(...a);
1325 }
1326 webhooks(...a: Parameters<Collab["webhooks"]>) {
1327 return this.collab.webhooks(...a);
1328 }
1329 deliverableWebhooks(...a: Parameters<Collab["deliverableWebhooks"]>) {
1330 return this.collab.deliverableWebhooks(...a);
1331 }
1332 createWebhook(url: string, events: string[], secret?: string) {
1333 return this.collab.createWebhook(url, events, secret, new URL(this.env.PUBLIC_URL).hostname);
1334 }
1335 deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) {
1336 return this.collab.deleteWebhook(...a);
1337 }
1338 recordDelivery(...a: Parameters<Collab["recordDelivery"]>) {
1339 return this.collab.recordDelivery(...a);
1340 }
1341
1342 enqueuePull(...a: Parameters<MergeQueue["enqueue"]>) {
1343 return this.queue.enqueue(...a);
1344 }
1345 dequeuePull(...a: Parameters<MergeQueue["dequeue"]>) {
1346 return this.queue.dequeue(...a);
1347 }
1348 mergeQueue(...a: Parameters<MergeQueue["list"]>) {
1349 return this.queue.list(...a);
1350 }
1351 mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) {
1352 return this.queue.settings(...a);
1353 }
1354 /** A caller's own auto-merge; only the server turns one on for a branch rule (AutoMerge.applyRule). */
1355 enableAutoMerge(number: number, input: Parameters<AutoMerge["enable"]>[1], caller: Caller) {
1356 return this.autoMerge.enable(number, input, caller);
1357 }
1358 disableAutoMerge(...a: Parameters<AutoMerge["disable"]>) {
1359 return this.autoMerge.disable(...a);
1360 }
1361
1362 runs(...a: Parameters<Actions["listRuns"]>) {
1363 return this.actions.listRuns(...a);
1364 }
1365 run(...a: Parameters<Actions["getRun"]>) {
1366 return this.actions.getRun(...a);
1367 }
1368 runJobs(...a: Parameters<Actions["jobs"]>) {
1369 return this.actions.jobs(...a);
1370 }
1371 claimJobAsRunner(...a: Parameters<Actions["claimJobAsRunner"]>) {
1372 return this.actions.claimJobAsRunner(...a);
1373 }
1374 runnerRegistrationToken(...a: Parameters<Actions["runnerRegistrationToken"]>) {
1375 return this.actions.runnerRegistrationToken(...a);
1376 }
1377 registerRunner(...a: Parameters<Actions["registerRunner"]>) {
1378 return this.actions.registerRunner(...a);
1379 }
1380 runners(...a: Parameters<Actions["runners"]>) {
1381 return this.actions.runners(...a);
1382 }
1383 deleteRunner(...a: Parameters<Actions["deleteRunner"]>) {
1384 return this.actions.deleteRunner(...a);
1385 }
1386 jobStatus(jobId: string): string {
1387 return this.actions.getJob(jobId).status;
1388 }
1389 job(...a: Parameters<Actions["getJob"]>) {
1390 return this.actions.getJob(...a);
1391 }
1392 jobLogs(...a: Parameters<Actions["logs"]>) {
1393 return this.actions.logs(...a);
1394 }
1395 cancelRun(...a: Parameters<Actions["cancel"]>) {
1396 return this.actions.cancel(...a);
1397 }
1398 deleteRunLogs(...a: Parameters<Actions["deleteLogs"]>) {
1399 return this.actions.deleteLogs(...a);
1400 }
1401 rerun(...a: Parameters<Actions["rerun"]>) {
1402 return this.actions.rerun(...a);
1403 }
1404 rerunJob(...a: Parameters<Actions["rerunJob"]>) {
1405 return this.actions.rerunJob(...a);
1406 }
1407 dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) {
1408 return this.actions.dispatch(...a);
1409 }
1410 scheduleTick(...a: Parameters<Actions["schedule"]>) {
1411 return this.actions.schedule(...a);
1412 }
1413 onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) {
1414 return this.actions.onPullRequest(...a);
1415 }
1416 workflows(...a: Parameters<Actions["listWorkflows"]>) {
1417 return this.actions.listWorkflows(...a);
1418 }
1419 runPlan(...a: Parameters<Actions["plan"]>) {
1420 return this.actions.plan(...a);
1421 }
1422 startJobKey(...a: Parameters<Actions["startJobKey"]>) {
1423 return this.actions.startJobKey(...a);
1424 }
1425 jobGate(...a: Parameters<Actions["jobGate"]>) {
1426 return this.actions.jobGate(...a);
1427 }
1428 pendingDeployments(...a: Parameters<Actions["pendingDeployments"]>) {
1429 return this.actions.pendingDeployments(...a);
1430 }
1431 reviewDeployments(...a: Parameters<Actions["reviewDeployments"]>) {
1432 return this.actions.reviewDeployments(...a);
1433 }
1434 deploymentReviews(...a: Parameters<Actions["deploymentReviews"]>) {
1435 return this.actions.deploymentReviews(...a);
1436 }
1437 jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) {
1438 return this.actions.jobTimedOut(...a);
1439 }
1440 finishRun(...a: Parameters<Actions["finishRun"]>) {
1441 return this.actions.finishRun(...a);
1442 }
1443 oidcClaims(...a: Parameters<Actions["oidcClaims"]>) {
1444 return this.actions.oidcClaims(...a);
1445 }
1446 runnerSpec(...a: Parameters<Actions["runnerSpec"]>) {
1447 return this.actions.runnerSpec(...a);
1448 }
1449 runnerLogs(...a: Parameters<Actions["runnerLogs"]>) {
1450 return this.actions.runnerLogs(...a);
1451 }
1452 runnerStep(...a: Parameters<Actions["runnerStep"]>) {
1453 return this.actions.runnerStep(...a);
1454 }
1455 runnerComplete(...a: Parameters<Actions["runnerComplete"]>) {
1456 return this.actions.runnerComplete(...a);
1457 }
1458 packagesPermission(...a: Parameters<Actions["packagesPermission"]>) {
1459 return this.actions.packagesPermission(...a);
1460 }
1461 runnerAuth(...a: Parameters<Actions["runnerAuth"]>) {
1462 return this.actions.runnerAuth(...a);
1463 }
1464 containerStopped(...a: Parameters<Actions["containerStopped"]>) {
1465 return this.actions.containerStopped(...a);
1466 }
1467 relaunchJob(...a: Parameters<Actions["relaunchJob"]>) {
1468 return this.actions.relaunchJob(...a);
1469 }
1470 setSecret(...a: Parameters<Actions["setSecret"]>) {
1471 return this.actions.setSecret(...a);
1472 }
1473 deleteSecret(...a: Parameters<Actions["deleteSecret"]>) {
1474 return this.actions.deleteSecret(...a);
1475 }
1476 secretNames(...a: Parameters<Actions["secretNames"]>) {
1477 return this.actions.secretNames(...a);
1478 }
1479 setVariable(...a: Parameters<Actions["setVariable"]>) {
1480 return this.actions.setVariable(...a);
1481 }
1482 variables(...a: Parameters<Actions["variables"]>) {
1483 return this.actions.variables(...a);
1484 }
1485 cacheLookup(...a: Parameters<Actions["cacheLookup"]>) {
1486 return this.actions.cacheLookup(...a);
1487 }
1488 cacheSaved(...a: Parameters<Actions["cacheSaved"]>) {
1489 return this.actions.cacheSaved(...a);
1490 }
1491 artifactUploadKeys(...a: Parameters<Actions["artifactUploadKeys"]>) {
1492 return this.actions.artifactUploadKeys(...a);
1493 }
1494 artifactSaved(...a: Parameters<Actions["artifactSaved"]>) {
1495 return this.actions.artifactSaved(...a);
1496 }
1497 artifacts(...a: Parameters<Actions["artifacts"]>) {
1498 return this.actions.artifacts(...a);
1499 }
1500 createSandbox(...a: Parameters<Actions["createSandbox"]>) {
1501 return this.actions.createSandbox(...a);
1502 }
1503 sandbox(...a: Parameters<Actions["getSandbox"]>) {
1504 return this.actions.getSandbox(...a);
1505 }
1506 closeSandbox(...a: Parameters<Actions["closeSandbox"]>) {
1507 return this.actions.closeSandbox(...a);
1508 }
1509 closeTaskSandbox(...a: Parameters<Actions["closeTaskSandbox"]>) {
1510 return this.actions.closeTaskSandbox(...a);
1511 }
1512 /** A kill switch was turned on (lib/killswitch.ts): stop what runs here now. */
1513 stopActions(...a: Parameters<Actions["stopActions"]>) {
1514 return this.actions.stopActions(...a);
1515 }
1516
1517 plannedRunError(runId: string, message: string) {
1518 return this.actions.plannedError(runId, message);
1519 }
1520 cacheSaveKeys(...a: Parameters<Actions["cacheSaveKeys"]>) {
1521 return this.actions.cacheSaveKeys(...a);
1522 }
1523 cacheReadKeys(...a: Parameters<Actions["cacheReadKeys"]>) {
1524 return this.actions.cacheReadKeys(...a);
1525 }
1526 artifactKeysFor(...a: Parameters<Actions["artifactKeysFor"]>) {
1527 return this.actions.artifactKeysFor(...a);
1528 }
1529 artifactQuota() {
1530 return this.runtime.artifactQuota;
1531 }
1532
1533 // ---------------------------------------------------------------- RPC passthroughs (Actions runtime services)
1534 // The cache and artifact services of repo/runtime.ts, called by http/actions-runtime.ts
1535 // with the job named by a runtime token, and by the REST caches/artifacts routes.
1536
1537 rtJob(...a: Parameters<Runtime["job"]>) {
1538 return this.runtime.job(...a);
1539 }
1540 rtGrantFor(...a: Parameters<Runtime["grantFor"]>) {
1541 return this.runtime.grantFor(...a);
1542 }
1543 rtCacheLookup(...a: Parameters<Runtime["cacheLookup"]>) {
1544 return this.runtime.cacheLookup(...a);
1545 }
1546 rtCacheReserve(...a: Parameters<Runtime["cacheReserve"]>) {
1547 return this.runtime.cacheReserve(...a);
1548 }
1549 rtCacheReservation(...a: Parameters<Runtime["cacheReservation"]>) {
1550 return this.runtime.cacheReservation(...a);
1551 }
1552 rtCacheWritable(...a: Parameters<Runtime["cacheWritable"]>) {
1553 return this.runtime.cacheWritable(...a);
1554 }
1555 rtCacheCommit(...a: Parameters<Runtime["cacheCommit"]>) {
1556 return this.runtime.cacheCommit(...a);
1557 }
1558 rtCacheGet(...a: Parameters<Runtime["cacheGet"]>) {
1559 return this.runtime.cacheGet(...a);
1560 }
1561 rtListCaches(...a: Parameters<Runtime["listCaches"]>) {
1562 return this.runtime.listCaches(...a);
1563 }
1564 rtDeleteCaches(...a: Parameters<Runtime["deleteCaches"]>) {
1565 return this.runtime.deleteCaches(...a);
1566 }
1567 rtCacheUsage(...a: Parameters<Runtime["cacheUsage"]>) {
1568 return this.runtime.cacheUsage(...a);
1569 }
1570 rtArtifactCreate(...a: Parameters<Runtime["artifactCreate"]>) {
1571 return this.runtime.artifactCreate(...a);
1572 }
1573 rtArtifactUpload(...a: Parameters<Runtime["artifactUpload"]>) {
1574 return this.runtime.artifactUpload(...a);
1575 }
1576 rtArtifactWritable(...a: Parameters<Runtime["artifactWritable"]>) {
1577 return this.runtime.artifactWritable(...a);
1578 }
1579 rtArtifactFinalize(...a: Parameters<Runtime["artifactFinalize"]>) {
1580 return this.runtime.artifactFinalize(...a);
1581 }
1582 rtArtifactGet(...a: Parameters<Runtime["artifactGet"]>) {
1583 return this.runtime.artifactGet(...a);
1584 }
1585 rtJobArtifacts(...a: Parameters<Runtime["jobArtifacts"]>) {
1586 return this.runtime.jobArtifacts(...a);
1587 }
1588 rtRunArtifacts(...a: Parameters<Runtime["runArtifacts"]>) {
1589 return this.runtime.runArtifacts(...a);
1590 }
1591 rtRepoArtifacts(...a: Parameters<Runtime["repoArtifacts"]>) {
1592 return this.runtime.repoArtifacts(...a);
1593 }
1594 rtArtifactDelete(...a: Parameters<Runtime["artifactDelete"]>) {
1595 return this.runtime.artifactDelete(...a);
1596 }
1597 rtArtifactDeleteByName(...a: Parameters<Runtime["artifactDeleteByName"]>) {
1598 return this.runtime.artifactDeleteByName(...a);
1599 }
1600 workflowSettings() {
1601 return this.policy.settings();
1602 }
1603 setWorkflowSettings(...a: Parameters<TokenPolicy["setSettings"]>) {
1604 return this.policy.setSettings(...a);
1605 }
1606 rtStage(...a: Parameters<Runtime["stage"]>) {
1607 return this.runtime.stage(...a);
1608 }
1609 rtUnstage(...a: Parameters<Runtime["unstage"]>) {
1610 return this.runtime.unstage(...a);
1611 }
1612 rtCheckCommitSize(...a: Parameters<Runtime["checkCommitSize"]>) {
1613 return this.runtime.checkCommitSize(...a);
1614 }
1615 rtCacheMissing(...a: Parameters<Runtime["cacheMissing"]>) {
1616 return this.runtime.cacheMissing(...a);
1617 }
1618 rtComposeStep(...a: Parameters<Runtime["composeStep"]>) {
1619 return this.runtime.composeStep(...a);
1620 }
1621
1622 // ---------------------------------------------------------------- RPC passthroughs (GitHub-compatible extras)
1623 // Releases, commit statuses, deployments and environments, issue sub-resources
1624 // (labels, reactions, events, locking), the Git data API, search and misc.
1625 // The modules are created on first use.
1626
1627 private extras: { releases?: Releases; statuses?: Statuses; deploys?: Deployments; social?: Social; gitdata?: GitData } = {};
1628 get releases(): Releases {
1629 return (this.extras.releases ??= new Releases(this, this.env));
1630 }
1631 get statuses(): Statuses {
1632 return (this.extras.statuses ??= new Statuses(this));
1633 }
1634 get deploys(): Deployments {
1635 return (this.extras.deploys ??= new Deployments(this, this.env));
1636 }
1637 get social(): Social {
1638 return (this.extras.social ??= new Social(this));
1639 }
1640 get gitdata(): GitData {
1641 return (this.extras.gitdata ??= new GitData(this, this.env));
1642 }
1643
1644 listReleases(...a: Parameters<Releases["list"]>) {
1645 return this.releases.list(...a);
1646 }
1647 getRelease(...a: Parameters<Releases["get"]>) {
1648 return this.releases.get(...a);
1649 }
1650 releaseByTag(...a: Parameters<Releases["byTag"]>) {
1651 return this.releases.byTag(...a);
1652 }
1653 latestRelease() {
1654 return this.releases.latest();
1655 }
1656 releaseSummary(...a: Parameters<Releases["summary"]>) {
1657 return this.releases.summary(...a);
1658 }
1659 createRelease(...a: Parameters<Releases["create"]>) {
1660 return this.releases.create(...a);
1661 }
1662 updateRelease(...a: Parameters<Releases["update"]>) {
1663 return this.releases.update(...a);
1664 }
1665 deleteRelease(...a: Parameters<Releases["remove"]>) {
1666 return this.releases.remove(...a);
1667 }
1668 generateReleaseNotes(...a: Parameters<Releases["generateNotes"]>) {
1669 return this.releases.generateNotes(...a);
1670 }
1671 releaseAssets(...a: Parameters<Releases["assets"]>) {
1672 return this.releases.assets(...a);
1673 }
1674 releaseAsset(...a: Parameters<Releases["asset"]>) {
1675 return this.releases.asset(...a);
1676 }
1677 updateReleaseAsset(...a: Parameters<Releases["updateAsset"]>) {
1678 return this.releases.updateAsset(...a);
1679 }
1680 deleteReleaseAsset(...a: Parameters<Releases["deleteAsset"]>) {
1681 return this.releases.deleteAsset(...a);
1682 }
1683 startReleaseAsset(...a: Parameters<Releases["startAsset"]>) {
1684 return this.releases.startAsset(...a);
1685 }
1686 finishReleaseAsset(...a: Parameters<Releases["finishAsset"]>) {
1687 return this.releases.finishAsset(...a);
1688 }
1689 abortReleaseAsset(...a: Parameters<Releases["abortAsset"]>) {
1690 return this.releases.abortAsset(...a);
1691 }
1692 releaseDownload(...a: Parameters<Releases["download"]>) {
1693 return this.releases.download(...a);
1694 }
1695 /** For the GitHub importer: a release with its original dates and author (no tag, events or workflows). */
1696 importRelease(...a: Parameters<Releases["importRelease"]>) {
1697 return this.releases.importRelease(...a);
1698 }
1699 /** For the GitHub importer: an asset copied from a public https URL, or from an R2 key under this repository's prefix. */
1700 importReleaseAsset(...a: Parameters<Releases["importAsset"]>) {
1701 return this.releases.importAsset(...a);
1702 }
1703
1704 createStatus(...a: Parameters<Statuses["create"]>) {
1705 return this.statuses.create(...a);
1706 }
1707 commitStatuses(...a: Parameters<Statuses["list"]>) {
1708 return this.statuses.list(...a);
1709 }
1710 combinedStatus(...a: Parameters<Statuses["combined"]>) {
1711 return this.statuses.combined(...a);
1712 }
1713 /** Check runs and commit statuses together, as pull request and commit pages show them. */
1714 checksAndStatuses(sha: string) {
1715 return [...this.collab.checks(sha), ...this.statuses.asChecks(sha)];
1716 }
1717
1718 /** This repository's part of the Today view (agentic/today.ts): open pull requests and what landed or failed since `since`. */
1719 /** Pull requests whose mergeability a Today summary is checking in the background. */
1720 private todayMergeChecks = new Set<number>();
1721
1722 todaySummary(since: string) {
1723 // Agents' auto-merges wait for a person's approval unless the base branch's rule waives it (repo/collab.ts mergeability).
1724 const waived = (base: string) => {
1725 const rules = this.protectionFor(base);
1726 return !!rules?.agentAutoMergeWithoutApproval && !rules.agentsMayMerge;
1727 };
1728 const summary = repoToday(this.sql, (sha) => this.checksAndStatuses(sha), since, waived);
1729 // After answering, find out whether agents' pull requests that may be waiting for a person conflict
1730 // or are already in their base, so the next Today leaves those out of "needs you".
1731 const unchecked = summary.pulls.filter((p) => needsMergeCheck(p) && !this.todayMergeChecks.has(p.number)).slice(0, MERGE_CHECKS_PER_SUMMARY);
1732 for (const p of unchecked) this.todayMergeChecks.add(p.number);
1733 if (unchecked.length)
1734 this.background(
1735 (async () => {
1736 for (const p of unchecked) {
1737 await this.collab.mergeability(p.number).catch((e) => console.error("today: mergeability check failed", p.number, e));
1738 this.todayMergeChecks.delete(p.number);
1739 }
1740 })(),
1741 );
1742 return summary;
1743 }
1744
1745 createDeployment(...a: Parameters<Deployments["create"]>) {
1746 return this.deploys.create(...a);
1747 }
1748 deployments(...a: Parameters<Deployments["list"]>) {
1749 return this.deploys.list(...a);
1750 }
1751 deployment(...a: Parameters<Deployments["get"]>) {
1752 return this.deploys.get(...a);
1753 }
1754 deleteDeployment(...a: Parameters<Deployments["remove"]>) {
1755 return this.deploys.remove(...a);
1756 }
1757 createDeploymentStatus(...a: Parameters<Deployments["createStatus"]>) {
1758 return this.deploys.createStatus(...a);
1759 }
1760 deploymentStatuses(...a: Parameters<Deployments["statuses"]>) {
1761 return this.deploys.statuses(...a);
1762 }
1763 deploymentStatus(...a: Parameters<Deployments["status"]>) {
1764 return this.deploys.status(...a);
1765 }
1766 environments() {
1767 return this.deploys.environments();
1768 }
1769 environment(...a: Parameters<Deployments["environment"]>) {
1770 return this.deploys.environment(...a);
1771 }
1772 upsertEnvironment(...a: Parameters<Deployments["upsertEnvironment"]>) {
1773 return this.deploys.upsertEnvironment(...a);
1774 }
1775 deleteEnvironment(...a: Parameters<Deployments["deleteEnvironment"]>) {
1776 return this.deploys.deleteEnvironment(...a);
1777 }
1778 addEnvironmentBranchPattern(...a: Parameters<Deployments["addBranchPattern"]>) {
1779 return this.deploys.addBranchPattern(...a);
1780 }
1781 removeEnvironmentBranchPattern(...a: Parameters<Deployments["removeBranchPattern"]>) {
1782 return this.deploys.removeBranchPattern(...a);
1783 }
1784 setEnvironmentSecret(...a: Parameters<Deployments["setSecret"]>) {
1785 return this.deploys.setSecret(...a);
1786 }
1787 deleteEnvironmentSecret(...a: Parameters<Deployments["deleteSecret"]>) {
1788 return this.deploys.deleteSecret(...a);
1789 }
1790 environmentSecretNames(...a: Parameters<Deployments["secretNames"]>) {
1791 return this.deploys.secretNames(...a);
1792 }
1793 setEnvironmentVariable(...a: Parameters<Deployments["setVariable"]>) {
1794 return this.deploys.setVariable(...a);
1795 }
1796 environmentVariables(...a: Parameters<Deployments["variables"]>) {
1797 return this.deploys.variables(...a);
1798 }
1799
1800 /** What the GitHub JSON builders need for a set of issues: label rows, reaction counts and lock reasons. */
1801 issueExtras(numbers: number[], subjects: string[] = []) {
1802 const lockReasons: Record<number, string | null> = {};
1803 for (const n of numbers) lockReasons[n] = this.social.lockReason(n);
1804 return { labels: this.social.labelRows(), reactions: this.social.reactionCounts([...numbers.map((n) => `issue:${n}`), ...subjects]), lockReasons };
1805 }
1806 issueEvents(...a: Parameters<Social["events"]>) {
1807 return this.social.events(...a);
1808 }
1809 repoIssueEvents(...a: Parameters<Social["repoEvents"]>) {
1810 return this.social.repoEvents(...a);
1811 }
1812 issueEvent(...a: Parameters<Social["event"]>) {
1813 return this.social.event(...a);
1814 }
1815 issueTimeline(...a: Parameters<Social["timeline"]>) {
1816 return this.social.timeline(...a);
1817 }
1818 labelRows() {
1819 return this.social.labelRows();
1820 }
1821 label(...a: Parameters<Social["label"]>) {
1822 return this.social.label(...a);
1823 }
1824 createLabel(...a: Parameters<Social["createLabel"]>) {
1825 return this.social.createLabel(...a);
1826 }
1827 updateLabel(...a: Parameters<Social["updateLabel"]>) {
1828 return this.social.updateLabel(...a);
1829 }
1830 deleteLabel(...a: Parameters<Social["deleteLabel"]>) {
1831 return this.social.deleteLabel(...a);
1832 }
1833 issueLabels(...a: Parameters<Social["issueLabels"]>) {
1834 return this.social.issueLabels(...a);
1835 }
1836 addIssueLabels(...a: Parameters<Social["addLabels"]>) {
1837 return this.social.addLabels(...a);
1838 }
1839 setIssueLabels(...a: Parameters<Social["setLabels"]>) {
1840 return this.social.setLabels(...a);
1841 }
1842 removeIssueLabel(...a: Parameters<Social["removeLabel"]>) {
1843 return this.social.removeLabel(...a);
1844 }
1845 assignIssue(...a: Parameters<Social["assign"]>) {
1846 return this.social.assign(...a);
1847 }
1848 lockIssue(...a: Parameters<Social["lock"]>) {
1849 return this.social.lock(...a);
1850 }
1851 unlockIssue(...a: Parameters<Social["unlock"]>) {
1852 return this.social.unlock(...a);
1853 }
1854 comment(...a: Parameters<Social["comment"]>) {
1855 return this.social.comment(...a);
1856 }
1857 deleteComment(...a: Parameters<Social["deleteComment"]>) {
1858 return this.social.deleteComment(...a);
1859 }
1860 repoComments(...a: Parameters<Social["repoComments"]>) {
1861 return this.social.repoComments(...a);
1862 }
1863 createReviewComment(...a: Parameters<Social["createReviewComment"]>) {
1864 return this.social.createReviewComment(...a);
1865 }
1866 review(...a: Parameters<Social["review"]>) {
1867 return this.social.review(...a);
1868 }
1869 dismissReview(...a: Parameters<Social["dismissReview"]>) {
1870 return this.social.dismissReview(...a);
1871 }
1872 removeReviewers(...a: Parameters<Social["removeReviewers"]>) {
1873 return this.social.removeReviewers(...a);
1874 }
1875 updatePullBranch(...a: Parameters<Social["updateBranch"]>) {
1876 return this.social.updateBranch(...a);
1877 }
1878 pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) {
1879 return this.social.pullsForCommit(...a);
1880 }
1881 reactions(...a: Parameters<Social["reactions"]>) {
1882 return this.social.reactions(...a);
1883 }
1884 react(...a: Parameters<Social["react"]>) {
1885 return this.social.react(...a);
1886 }
1887 unreact(...a: Parameters<Social["unreact"]>) {
1888 return this.social.unreact(...a);
1889 }
1890 searchIssues(query: Parameters<typeof searchIssues>[1], limit: number) {
1891 return searchIssues(this, query, limit);
1892 }
1893 /** The issues for one page of search hits, in the order given (missing numbers are skipped). */
1894 issuesByNumber(numbers: number[], hide?: string[]) {
1895 return numbers.slice(0, 100).flatMap((n) => {
1896 try {
1897 return [this.collab.get(n, hide).issue];
1898 } catch {
1899 return [];
1900 }
1901 });
1902 }
1903
1904 objectType(...a: Parameters<GitData["objectType"]>) {
1905 return this.gitdata.objectType(...a);
1906 }
1907 refsWithTypes(...a: Parameters<GitData["refsWithTypes"]>) {
1908 return this.gitdata.refsWithTypes(...a);
1909 }
1910 createBlob(content: Uint8Array, caller: Caller) {
1911 return this.gitdata.createBlob(content, caller);
1912 }
1913 getBlob(...a: Parameters<GitData["getBlob"]>) {
1914 return this.gitdata.getBlob(...a);
1915 }
1916 createTree(...a: Parameters<GitData["createTree"]>) {
1917 return this.gitdata.createTree(...a);
1918 }
1919 getTree(...a: Parameters<GitData["getTree"]>) {
1920 return this.gitdata.getTree(...a);
1921 }
1922 createGitCommit(...a: Parameters<GitData["createCommit"]>) {
1923 return this.gitdata.createCommit(...a);
1924 }
1925 gitCommit(...a: Parameters<GitData["gitCommit"]>) {
1926 return this.gitdata.gitCommit(...a);
1927 }
1928 createGitTag(...a: Parameters<GitData["createTag"]>) {
1929 return this.gitdata.createTag(...a);
1930 }
1931 gitTag(...a: Parameters<GitData["getTag"]>) {
1932 return this.gitdata.getTag(...a);
1933 }
1934 putContents(...a: Parameters<GitData["putContents"]>) {
1935 return this.gitdata.putContents(...a);
1936 }
1937 deleteContents(...a: Parameters<GitData["deleteContents"]>) {
1938 return this.gitdata.deleteContents(...a);
1939 }
1940 readme(...a: Parameters<GitData["readme"]>) {
1941 return this.gitdata.readme(...a);
1942 }
1943 mergeBranches(...a: Parameters<GitData["merge"]>) {
1944 return this.gitdata.merge(...a);
1945 }
1946 languages() {
1947 return this.gitdata.languages();
1948 }
1949 archiveKey(...a: Parameters<GitData["archiveKey"]>) {
1950 return this.gitdata.archiveKey(...a);
1951 }
1952 buildArchive(...a: Parameters<GitData["buildArchive"]>) {
1953 return this.gitdata.buildArchive(...a);
1954 }
1955 runLogsZip(...a: Parameters<GitData["runLogsZip"]>) {
1956 return this.gitdata.runLogsZip(...a);
1957 }
1958 runAttemptId(...a: Parameters<GitData["runAttemptId"]>) {
1959 return this.gitdata.runAttemptId(...a);
1960 }
1961 checkRun(...a: Parameters<GitData["check"]>) {
1962 return this.gitdata.check(...a);
1963 }
1964 updateCheckRun(...a: Parameters<GitData["updateCheck"]>) {
1965 return this.gitdata.updateCheck(...a);
1966 }
1967 checkSuites(...a: Parameters<GitData["checkSuites"]>) {
1968 return this.gitdata.checkSuites(...a);
1969 }
1970 checkSuite(...a: Parameters<GitData["checkSuite"]>) {
1971 return this.gitdata.checkSuite(...a);
1972 }
1973
1974 /** repository_dispatch: an external event that starts workflows listening for its `event_type`. */
1975 async repositoryDispatch(eventType: string, clientPayload: Record<string, unknown>, caller: Caller): Promise<void> {
1976 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to dispatch events");
1977 const sha = this.getRef(this.headRef);
1978 if (!sha) throw new HttpError(422, "the repository is empty");
1979 // Suspended Actions refuse the dispatch with the reason (triggerWorkflows would swallow it).
1980 await this.actions.refuseIfBlocked("run");
1981 const m = this.meta;
1982 const payload = { action: eventType, branch: m.defaultBranch, client_payload: clientPayload };
1983 this.emit({ type: "repository_dispatch", action: eventType, repoId: m.repoId, owner: m.owner, repo: m.name, payload: JSON.parse(JSON.stringify(payload)), actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() });
1984 await triggerWorkflows(this, { name: "repository_dispatch", action: eventType, ref: this.headRef, sha, payload, title: eventType }, caller);
1985 }
1986
1987 // ---------------------------------------------------------------- RPC passthroughs: GitHub imports and milestones (repo/imports.ts)
1988
1989 private importsCache?: RepoImports;
1990 private get importsModule(): RepoImports {
1991 return (this.importsCache ??= new RepoImports(this.sql));
1992 }
1993
1994 /** The import that currently holds this repository, or null. Other writes are refused meanwhile (loadRepo, gitReceivePack). */
1995 importLockHolder(): string | null {
1996 return this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'import_lock'").toArray()[0]?.value ?? null;
1997 }
1998
1999 /**
2000 * Claim the repository for an import. Refuses if another import holds it, or if the repo
2001 * already holds any ref, issue, comment, milestone, protection or release this import did not write
2002 * (so a retry never adopts content people added in the meantime — H2).
2003 */
2004 importBegin(importId: string): void {
2005 const holder = this.importLockHolder();
2006 if (holder && holder !== importId) throw new HttpError(409, "another import is in progress for this repository");
2007 // A ref not written by this import, or any foreign row, means the repo was adopted since.
2008 for (const r of this.refs()) {
2009 if (r.name.startsWith("refs/diffinery-import/")) continue;
2010 if (this.importsModule.importRefOid(r.name, importId) === null) {
2011 if (holder !== importId) throw new HttpError(409, "repository already holds content not written by this import; start a new import");
2012 }
2013 }
2014 if (this.importsModule.hasForeignContent(importId)) throw new HttpError(409, "repository already holds content not written by this import; start a new import");
2015 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('import_lock', ?)", importId);
2016 }
2017
2018 /** Release the import lock (on finalize, fail or cancel). */
2019 importEnd(importId: string): void {
2020 if (this.importLockHolder() === importId) this.sql.exec("DELETE FROM meta WHERE key = 'import_lock'");
2021 }
2022
2023 importProtection(pattern: string, rules: ProtectionRules | null, importId: string) {
2024 // What agents may do is a person's decision here, never something an import brings in.
2025 if (rules) {
2026 const { allowAgentAutoMerge: _a, agentAutoMergeWithoutApproval: _w, autoMergeAgents: _g, autoMergeMethod: _gm, agentsMayPush: _p, agentsMayMerge: _m, ...rest } = rules;
2027 rules = rest;
2028 }
2029 if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) };
2030 if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) };
2031 if (rules) this.importsModule.protection(pattern, JSON.stringify(rules), importId);
2032 }
2033 importLabels(...a: Parameters<RepoImports["labels"]>) {
2034 return this.importsModule.labels(...a);
2035 }
2036 importMilestones(...a: Parameters<RepoImports["milestones"]>) {
2037 return this.importsModule.milestones(...a);
2038 }
2039 importIssues(...a: Parameters<RepoImports["issues"]>) {
2040 return this.importsModule.issues(...a);
2041 }
2042 importComments(...a: Parameters<RepoImports["comments"]>) {
2043 return this.importsModule.comments(...a);
2044 }
2045 importReviews(...a: Parameters<RepoImports["reviews"]>) {
2046 return this.importsModule.reviews(...a);
2047 }
2048 importSetMergedBy(...a: Parameters<RepoImports["setMergedBy"]>) {
2049 return this.importsModule.setMergedBy(...a);
2050 }
2051 importPullNumbers(...a: Parameters<RepoImports["pullNumbers"]>) {
2052 return this.importsModule.pullNumbers(...a);
2053 }
2054 importCounts() {
2055 return this.importsModule.counts();
2056 }
2057 importFindings() {
2058 return this.importsModule.findings();
2059 }
2060 /**
2061 * Point `refs/pull/N/head` at each imported pull request's head commit (the server owns
2062 * refs/pull/*; the agent pushed the commits to refs/diffinery-import/pull/N). Heads whose
2063 * commits never arrived (e.g. deleted forks) are reported, not fatal.
2064 */
2065 importPullRefs(importId?: string): { created: number; missing: number[] } {
2066 const missing: number[] = [];
2067 let created = 0;
2068 for (const p of this.importsModule.pullHeads()) {
2069 const ref = `refs/pull/${p.number}/head`;
2070 if (!/^[0-9a-f]{40}$/.test(p.headSha) || !this.store.has(p.headSha)) {
2071 missing.push(p.number);
2072 continue;
2073 }
2074 const current = this.getRef(ref);
2075 // Record provenance so a retry does not mistake refs/pull/*/head for content people adopted.
2076 if (importId) this.importsModule.recordImportRef(ref, p.headSha, importId);
2077 if (current === p.headSha) continue;
2078 if (this.casRef(ref, current, p.headSha)) created++;
2079 }
2080 return { created, missing };
2081 }
2082 /** Remove the import's own refs once refs/pull/N/head keep the pull request commits reachable. */
2083 importDropTempRefs(importId?: string): number {
2084 const refs = this.refs(IMPORT_REF_PREFIX);
2085 for (const r of refs) this.casRef(r.name, r.oid, null);
2086 if (importId) this.importsModule.dropImportRefs(importId);
2087 return refs.length;
2088 }
2089 setOrgVariables(...a: Parameters<RepoImports["setOrgVariables"]>) {
2090 return this.importsModule.setOrgVariables(...a);
2091 }
2092 issueMilestoneOf(number: number) {
2093 return this.importsModule.milestoneOf(number);
2094 }
2095 /** Check names that workflow jobs on the default branch report, keyed by job name (GitHub names checks by job). */
2096 async importCheckNames(): Promise<Record<string, string[]>> {
2097 const sha = await this.resolve("HEAD");
2098 const out: Record<string, string[]> = {};
2099 if (!sha) return out;
2100 for (const w of await this.actions.loadWorkflows(sha)) {
2101 if (!w.workflow) continue;
2102 const wf = w.workflow.name ?? w.path.split("/").pop()!;
2103 for (const [key, job] of Object.entries(w.workflow.jobs)) {
2104 const name = (job as { name?: unknown }).name;
2105 for (const n of new Set([key, typeof name === "string" ? name : key])) (out[n] ??= []).push(`${wf} / ${typeof name === "string" && !name.includes("${{") ? name : key}`);
2106 }
2107 }
2108 return out;
2109 }
2110 milestones(...a: Parameters<RepoImports["listMilestones"]>) {
2111 return this.importsModule.listMilestones(...a);
2112 }
2113 milestone(...a: Parameters<RepoImports["milestone"]>) {
2114 return this.importsModule.milestone(...a);
2115 }
2116 saveMilestone(...a: Parameters<RepoImports["saveMilestone"]>) {
2117 return this.importsModule.saveMilestone(...a);
2118 }
2119 setIssueMilestone(...a: Parameters<RepoImports["setIssueMilestone"]>) {
2120 return this.importsModule.setIssueMilestone(...a);
2121 }
2122
2123 // ---------------------------------------------------------------- live updates (WebSockets with hibernation)
2124
2125 override async fetch(request: Request): Promise<Response> {
2126 if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
2127 const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10);
2128 const pair = new WebSocketPair();
2129 this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]);
2130 // Access is checked when a page connects; reconnecting checks it again, so someone who lost access
2131 // (removed, or the repository made private) stops getting updates within LIVE_REAUTH_MS.
2132 pair[1].serializeAttachment({ until: Date.now() + LIVE_REAUTH_MS });
2133 return new Response(null, { status: 101, webSocket: pair[0] });
2134 }
2135
2136 /** Close a socket whose access check is too old; the page reconnects, and the Worker checks access again. */
2137 private expired(ws: WebSocket): boolean {
2138 const a = ws.deserializeAttachment() as { until?: number } | null;
2139 if (a?.until && a.until > Date.now()) return false;
2140 try {
2141 ws.close(LIVE_REAUTH_CODE, "reconnect");
2142 } catch {
2143 /* already closed */
2144 }
2145 return true;
2146 }
2147
2148 override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
2149 if (this.expired(ws)) return;
2150 if (message === "ping") ws.send("pong");
2151 }
2152
2153 override async webSocketClose(ws: WebSocket, code: number): Promise<void> {
2154 try {
2155 ws.close(code, "bye");
2156 } catch {
2157 /* already closed */
2158 }
2159 }
2160
2161 broadcast(topic: string, data: unknown): void {
2162 const msg = JSON.stringify({ topic, data });
2163 for (const ws of this.ctx.getWebSockets(topic)) {
2164 if (this.expired(ws)) continue;
2165 try {
2166 ws.send(msg);
2167 } catch {
2168 /* client went away */
2169 }
2170 }
2171 }
2172}
2173
2174export function toInfo(sha: string, c: Commit): CommitInfo {
2175 return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig };
2176}
2177
2178export { fromUtf8 };