| 1 | import { DurableObject } from "cloudflare:workers"; |
| 2 | import { normalizeCheckSources } from "./required.js"; |
| 3 | import { unsafeTreePath } from "../lib/tree-names.js"; |
| 4 | import { |
| 5 | EMPTY_TREE_OID, |
| 6 | ZERO_OID, |
| 7 | applyChanges, |
| 8 | diffTrees, |
| 9 | fileDiff, |
| 10 | formatUnified, |
| 11 | fromUtf8, |
| 12 | isAncestor, |
| 13 | isBinary, |
| 14 | isOid, |
| 15 | isTreeMode, |
| 16 | mergeBase, |
| 17 | parsePack, |
| 18 | peelToCommit, |
| 19 | readCommit, |
| 20 | readTree, |
| 21 | resolvePath, |
| 22 | serializeCommit, |
| 23 | walkCommits, |
| 24 | type Commit, |
| 25 | type FileChange, |
| 26 | type Signature, |
| 27 | } from "@diffinery/git-core"; |
| 28 | import type { DiffineryEvent } from "@diffinery/shared"; |
| 29 | import type { Env } from "../env.js"; |
| 30 | import { |
| 31 | advertiseReceivePack, |
| 32 | advertiseUploadPackV2, |
| 33 | errorPacket, |
| 34 | handleUploadPackV2, |
| 35 | parseReceivePack, |
| 36 | reportStatus, |
| 37 | type RefEntry, |
| 38 | type RefResult, |
| 39 | } from "../git/protocol.js"; |
| 40 | import { scanForSecrets, secretBypassReason } from "../git/secret-scan.js"; |
| 41 | import { HttpError, isValidRefName, now } from "../lib/util.js"; |
| 42 | import { deletePrefixes, transientPrefixes } from "../lib/storage-keys.js"; |
| 43 | import { Actions } from "./actions.js"; |
| 44 | import { AutoMerge, isPerson } from "./auto-merge.js"; |
| 45 | import { Collab } from "./collab.js"; |
| 46 | import { Deployments } from "./deployments.js"; |
| 47 | import { triggerWorkflows } from "./extra-triggers.js"; |
| 48 | import { GitData } from "./gitdata.js"; |
| 49 | import { IMPORT_REF_PREFIX, RepoImports } from "./imports.js"; |
| 50 | import { RepoObjectStore } from "./objects.js"; |
| 51 | import { MergeQueue, QUEUE_BRANCH_PREFIX, normalizeMergeQueue } from "./queue.js"; |
| 52 | import { Releases } from "./releases.js"; |
| 53 | import { REPO_SCHEMA, migrateRepoSchema } from "./schema.js"; |
| 54 | import { Runtime } from "./runtime.js"; |
| 55 | import { TokenPolicy } from "./token-policy.js"; |
| 56 | import { searchIssues } from "./search.js"; |
| 57 | import { Social } from "./social.js"; |
| 58 | import { Statuses } from "./statuses.js"; |
| 59 | import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; |
| 60 | |
| 61 | const MAX_PUSH_BYTES = 100 * 1024 * 1024; |
| 62 | |
| 63 | export type TreeItem = { |
| 64 | name: string; |
| 65 | path: string; |
| 66 | type: "tree" | "blob" | "commit"; |
| 67 | mode: string; |
| 68 | oid: string; |
| 69 | size?: number; |
| 70 | }; |
| 71 | |
| 72 | export type CommitInfo = { |
| 73 | sha: string; |
| 74 | tree: string; |
| 75 | parents: string[]; |
| 76 | author: Signature; |
| 77 | committer: Signature; |
| 78 | message: string; |
| 79 | signed: boolean; |
| 80 | }; |
| 81 | |
| 82 | /** |
| 83 | * One Durable Object per repository. It is the single writer for the repo's |
| 84 | * refs, Git objects, issues, pull requests, reviews, checks and workflow runs, |
| 85 | * which gives strongly consistent ref updates and merges without locks. |
| 86 | */ |
| 87 | export class RepoDO extends DurableObject<Env> { |
| 88 | readonly sql: SqlStorage; |
| 89 | store!: RepoObjectStore; |
| 90 | readonly collab: Collab; |
| 91 | readonly actions: Actions; |
| 92 | readonly queue: MergeQueue; |
| 93 | readonly autoMerge: AutoMerge; |
| 94 | readonly runtime: Runtime; |
| 95 | readonly policy: TokenPolicy; |
| 96 | private metaCache: RepoMeta | null = null; |
| 97 | |
| 98 | constructor(ctx: DurableObjectState, env: Env) { |
| 99 | super(ctx, env); |
| 100 | this.sql = ctx.storage.sql; |
| 101 | this.sql.exec(REPO_SCHEMA); |
| 102 | migrateRepoSchema(this.sql); |
| 103 | const meta = this.readMeta(); |
| 104 | if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId); |
| 105 | this.collab = new Collab(this); |
| 106 | this.actions = new Actions(this, env); |
| 107 | this.queue = new MergeQueue(this); |
| 108 | this.autoMerge = new AutoMerge(this, env); |
| 109 | this.runtime = new Runtime(this, env); |
| 110 | this.policy = new TokenPolicy(this); |
| 111 | } |
| 112 | |
| 113 | // ---------------------------------------------------------------- meta |
| 114 | |
| 115 | private readMeta(): RepoMeta | null { |
| 116 | const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0]; |
| 117 | this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null; |
| 118 | return this.metaCache; |
| 119 | } |
| 120 | |
| 121 | get meta(): RepoMeta { |
| 122 | const m = this.metaCache ?? this.readMeta(); |
| 123 | if (!m) throw new HttpError(404, "repository not initialised"); |
| 124 | return m; |
| 125 | } |
| 126 | |
| 127 | async init(meta: RepoMeta): Promise<void> { |
| 128 | // A repository created now (not one whose meta is updated) starts with read-only workflow tokens. |
| 129 | if (!this.readMeta()) this.policy.initNewRepo(); |
| 130 | this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta)); |
| 131 | this.metaCache = meta; |
| 132 | this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId); |
| 133 | } |
| 134 | |
| 135 | async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> { |
| 136 | const next = { ...this.meta, ...patch }; |
| 137 | await this.init(next); |
| 138 | return next; |
| 139 | } |
| 140 | |
| 141 | /** Wipe all storage (repository deletion). */ |
| 142 | async destroy(): Promise<void> { |
| 143 | // Caches, artifacts, logs and archives live outside repos/<id>/; with the rows gone, nothing would remove them. |
| 144 | const repoId = this.readMeta()?.repoId; |
| 145 | if (repoId) await deletePrefixes(this.env.BUCKET, transientPrefixes(repoId)).catch((e) => console.error("could not delete the repository's Actions objects", e)); |
| 146 | await this.ctx.storage.deleteAll(); |
| 147 | this.metaCache = null; |
| 148 | } |
| 149 | |
| 150 | async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> { |
| 151 | const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n; |
| 152 | return { |
| 153 | objects: this.store.count(), |
| 154 | bytes: this.store.sizeBytes(), |
| 155 | refs: q("SELECT count(*) AS n FROM refs"), |
| 156 | openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"), |
| 157 | openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"), |
| 158 | }; |
| 159 | } |
| 160 | |
| 161 | /** Keep the object alive for work that finishes after the current request. */ |
| 162 | background(p: Promise<unknown>): void { |
| 163 | this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e))); |
| 164 | } |
| 165 | |
| 166 | /** Set (or with null, clear) the object's single alarm; the merge queue uses it for check timeouts. */ |
| 167 | armAlarm(at: number | null): void { |
| 168 | this.ctx.waitUntil(at === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(at)); |
| 169 | } |
| 170 | |
| 171 | override async alarm(): Promise<void> { |
| 172 | await this.queue.process(); |
| 173 | } |
| 174 | |
| 175 | /** 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). */ |
| 176 | emit(event: DiffineryEvent, opts?: { live?: boolean }): void { |
| 177 | this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e))); |
| 178 | if (opts?.live !== false) this.broadcast("repo", { kind: "event", event }); |
| 179 | } |
| 180 | |
| 181 | // ---------------------------------------------------------------- refs |
| 182 | |
| 183 | refs(prefix = "refs/"): RefEntry[] { |
| 184 | return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}`).toArray(); |
| 185 | } |
| 186 | |
| 187 | getRef(name: string): string | null { |
| 188 | return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null; |
| 189 | } |
| 190 | |
| 191 | /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */ |
| 192 | casRef(name: string, expected: string | null, next: string | null): boolean { |
| 193 | const current = this.getRef(name); |
| 194 | if (expected !== null && (current ?? ZERO_OID) !== expected) return false; |
| 195 | if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name); |
| 196 | else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next); |
| 197 | return true; |
| 198 | } |
| 199 | |
| 200 | get headRef(): string { |
| 201 | return `refs/heads/${this.meta.defaultBranch}`; |
| 202 | } |
| 203 | |
| 204 | /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */ |
| 205 | async resolve(rev: string | undefined | null): Promise<string | null> { |
| 206 | const r = rev && rev.length ? rev : "HEAD"; |
| 207 | let oid: string | null = null; |
| 208 | if (r === "HEAD") oid = this.getRef(this.headRef); |
| 209 | else if (isOid(r)) oid = this.store.has(r) ? r : null; |
| 210 | else if (r.startsWith("refs/")) oid = this.getRef(r); |
| 211 | else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`); |
| 212 | if (!oid && /^[0-9a-f]{7,39}$/.test(r)) { |
| 213 | const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray(); |
| 214 | if (rows.length === 1) oid = rows[0]!.oid; |
| 215 | } |
| 216 | if (!oid) return null; |
| 217 | return peelToCommit(this.store, oid); |
| 218 | } |
| 219 | |
| 220 | async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> { |
| 221 | return this.refs("refs/heads/").map((r) => { |
| 222 | const name = r.name.slice(11); |
| 223 | return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch }; |
| 224 | }); |
| 225 | } |
| 226 | |
| 227 | async tags(): Promise<Array<{ name: string; sha: string; target: string }>> { |
| 228 | const out = []; |
| 229 | 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) }); |
| 230 | return out; |
| 231 | } |
| 232 | |
| 233 | // ---------------------------------------------------------------- git smart HTTP |
| 234 | |
| 235 | async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> { |
| 236 | if (service === "git-upload-pack") return advertiseUploadPackV2(); |
| 237 | // Like GitHub, pull request refs are hidden from pushes (so `git push --mirror` leaves them alone). |
| 238 | return advertiseReceivePack(this.refs().filter((r) => !r.name.startsWith("refs/pull/"))); |
| 239 | } |
| 240 | |
| 241 | async gitUploadPack(body: Uint8Array): Promise<Uint8Array> { |
| 242 | try { |
| 243 | return await handleUploadPackV2(body, { store: this.store, refs: this.refs(), headTarget: this.headRef }); |
| 244 | } catch (e) { |
| 245 | return errorPacket((e as Error).message); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | /** `moved`: the push came in under an old name (a transfer or rename), so say where the repository lives now, like GitHub. */ |
| 250 | async gitReceivePack(body: Uint8Array, caller: Caller, o: { moved?: boolean } = {}): Promise<Uint8Array> { |
| 251 | if (body.length > MAX_PUSH_BYTES) { |
| 252 | const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }; |
| 253 | return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []); |
| 254 | } |
| 255 | const req = parseReceivePack(body); |
| 256 | // `git push -o diffinery-import` from a GitHub import's own token: no workflows, webhooks, |
| 257 | // pull request syncs or notifications (see apps/diffinery/src/imports). |
| 258 | const importMode = !!caller.importId && req.pushOptions.some((o) => o.trim() === "diffinery-import"); |
| 259 | // H2: while an import holds the repository, only that import may push. Everything else waits |
| 260 | // until the import finalises, so protection, guardrails, CI and webhooks are never bypassed. |
| 261 | const lockHolder = this.importLockHolder(); |
| 262 | if (lockHolder && (!importMode || caller.importId !== lockHolder)) { |
| 263 | const denied = req.commands.map((c) => ({ ref: c.ref, ok: false as const, reason: "repository is locked by an import in progress" })); |
| 264 | return reportStatus(req, "repository is locked by an import in progress", denied); |
| 265 | } |
| 266 | const messages: string[] = o.moved ? ["This repository moved. Please use the new location:", ` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}.git`] : []; |
| 267 | let unpackError: string | null = null; |
| 268 | // Like git's quarantine: nothing from the pack is stored until push protection passes, |
| 269 | // so a blocked push leaves no objects behind for a retry to build on. |
| 270 | let objects: Awaited<ReturnType<typeof parsePack>> = []; |
| 271 | if (req.pack && req.pack.length > 0) { |
| 272 | try { |
| 273 | objects = await parsePack(req.pack, (oid) => this.store.read(oid)); |
| 274 | } catch (e) { |
| 275 | unpackError = (e as Error).message; |
| 276 | } |
| 277 | } |
| 278 | const newBlobs = objects.filter((o) => o.type === "blob" && o.content.length < 1024 * 1024).map((o) => ({ oid: o.oid, content: o.content })); |
| 279 | const results: RefResult[] = []; |
| 280 | if (unpackError) { |
| 281 | for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" }); |
| 282 | return reportStatus(req, unpackError, results); |
| 283 | } |
| 284 | |
| 285 | const leaks = scanForSecrets(newBlobs); |
| 286 | if (leaks.length) { |
| 287 | const kinds = leaks.map((l) => l.kind); |
| 288 | const target = `${this.meta.owner}/${this.meta.name}`; |
| 289 | // Like GitHub's push protection bypass: a human repository admin may push anyway by |
| 290 | // giving a reason (`git push -o allow-secrets=<reason>`), and every bypass is audited. |
| 291 | const reason = secretBypassReason(req.pushOptions); |
| 292 | if (reason && caller.role === "admin" && caller.kind === "user") { |
| 293 | messages.push(`Push protection bypassed by ${caller.login}: ${reason}`); |
| 294 | for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); |
| 295 | 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() }); |
| 296 | if (importMode) this.importsModule.recordFindings(leaks); |
| 297 | } else { |
| 298 | messages.push("Push blocked: possible secrets detected."); |
| 299 | for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); |
| 300 | messages.push("Remove the secret from your commits, or rotate it and contact an admin."); |
| 301 | if (reason) messages.push("Only repository admins (people, not agents) can bypass push protection."); |
| 302 | for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" }); |
| 303 | this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target, detail: { kinds, refs: req.commands.map((c) => c.ref) }, at: now() }); |
| 304 | return reportStatus(req, null, results, messages); |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | try { |
| 309 | for (const o of objects) await this.store.writeKnown(o.oid, o.type, o.content); |
| 310 | } catch (e) { |
| 311 | for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" }); |
| 312 | return reportStatus(req, (e as Error).message, results); |
| 313 | } |
| 314 | |
| 315 | // Validate every command before applying any, so `atomic` is honoured. |
| 316 | const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = []; |
| 317 | for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller, importMode)) }); |
| 318 | const atomic = req.capabilities.has("atomic"); |
| 319 | const anyFailed = checks.some((c) => !c.ok); |
| 320 | let imported = 0; |
| 321 | for (const c of checks) { |
| 322 | if (!c.ok) { |
| 323 | results.push({ ref: c.ref, ok: false, reason: c.reason }); |
| 324 | continue; |
| 325 | } |
| 326 | if (atomic && anyFailed) { |
| 327 | results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" }); |
| 328 | continue; |
| 329 | } |
| 330 | const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid); |
| 331 | results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" }); |
| 332 | if (applied && importMode) { |
| 333 | imported++; |
| 334 | // Record provenance so a later push may move this ref only if the import still owns it. |
| 335 | if (c.cmd.newOid === ZERO_OID) this.sql.exec("DELETE FROM import_refs WHERE ref = ?", c.cmd.ref); |
| 336 | else this.importsModule.recordImportRef(c.cmd.ref, c.cmd.newOid, caller.importId!); |
| 337 | } else if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages); |
| 338 | } |
| 339 | // One audit entry per import push instead of push events. |
| 340 | 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() }); |
| 341 | return reportStatus(req, null, results, messages); |
| 342 | } |
| 343 | |
| 344 | /** As on GitHub: a rule naming the branch exactly wins; otherwise the oldest matching pattern. */ |
| 345 | protectionFor(branch: string): ProtectionRules | null { |
| 346 | const rows = this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY rowid").toArray(); |
| 347 | const row = rows.find((r) => r.pattern === branch) ?? rows.find((r) => globToRegExp(r.pattern).test(branch)); |
| 348 | return row ? (JSON.parse(row.rules) as ProtectionRules) : null; |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails. |
| 353 | * `importMode`: a GitHub import filling the repository it created, which is the only writer |
| 354 | * until it finishes, so branch rules (copied from GitHub at its end) don't apply to it. |
| 355 | */ |
| 356 | async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, importMode = false): Promise<RefResult> { |
| 357 | if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" }; |
| 358 | if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" }; |
| 359 | const deleting = newOid === ZERO_OID; |
| 360 | if (ref.startsWith("refs/pull/")) return { ref, ok: false, reason: "refs/pull/ is managed by Diffinery for pull requests" }; |
| 361 | if (ref.startsWith(`refs/heads/${QUEUE_BRANCH_PREFIX}`)) return { ref, ok: false, reason: "merge queue branches are read-only" }; |
| 362 | if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" }; |
| 363 | if (ref.startsWith(IMPORT_REF_PREFIX) && !importMode) return { ref, ok: false, reason: `${IMPORT_REF_PREFIX} is reserved for GitHub imports` }; |
| 364 | if (importMode) { |
| 365 | if (!/^refs\/(heads|tags|diffinery-import)\//.test(ref)) return { ref, ok: false, reason: "imports write branches and tags only" }; |
| 366 | // H2: create a ref, or move one this import previously wrote — never a ref adopted since. |
| 367 | const current = this.getRef(ref); |
| 368 | if (current && !deleting) { |
| 369 | const owned = this.importsModule.importRefOid(ref, caller.importId!); |
| 370 | if (owned !== current) return { ref, ok: false, reason: "import may not overwrite a ref it did not write" }; |
| 371 | } |
| 372 | return { ref, ok: true }; |
| 373 | } |
| 374 | // A workflow's GITHUB_TOKEN never adds or changes workflow files (GitHub's `workflows` permission). |
| 375 | const workflowEdit = await this.policy.refuseWorkflowEdit(caller, oldOid, newOid); |
| 376 | if (workflowEdit) return { ref, ok: false, reason: workflowEdit }; |
| 377 | if (!ref.startsWith("refs/heads/")) { |
| 378 | if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" }; |
| 379 | return { ref, ok: true }; |
| 380 | } |
| 381 | const branch = ref.slice(11); |
| 382 | const rules = this.protectionFor(branch); |
| 383 | const isDefault = branch === this.meta.defaultBranch; |
| 384 | if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) { |
| 385 | return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" }; |
| 386 | } |
| 387 | if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" }; |
| 388 | if (!rules) return { ref, ok: true }; |
| 389 | const bypass = caller.role === "admin" && !rules.enforceAdmins; |
| 390 | if (bypass) return { ref, ok: true }; |
| 391 | if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" }; |
| 392 | if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" }; |
| 393 | if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) { |
| 394 | return { ref, ok: false, reason: "protected branch: force push not allowed" }; |
| 395 | } |
| 396 | if (rules.requireLinearHistory) { |
| 397 | const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 }); |
| 398 | if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" }; |
| 399 | } |
| 400 | return { ref, ok: true }; |
| 401 | } |
| 402 | |
| 403 | /** Side effects after a ref moved: events, PR sync, workflow triggers. */ |
| 404 | async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> { |
| 405 | const at = now(); |
| 406 | const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid)); |
| 407 | const ev: DiffineryEvent = { |
| 408 | type: "push", |
| 409 | repoId: this.meta.repoId, |
| 410 | owner: this.meta.owner, |
| 411 | repo: this.meta.name, |
| 412 | ref, |
| 413 | before: oldOid, |
| 414 | after: newOid, |
| 415 | actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, |
| 416 | forced, |
| 417 | created: oldOid === ZERO_OID, |
| 418 | deleted: newOid === ZERO_OID, |
| 419 | at, |
| 420 | }; |
| 421 | this.emit(ev); |
| 422 | this.policy.recordPush(ref, newOid, caller); |
| 423 | this.deploys.noteRefUpdate(ref, newOid, caller); |
| 424 | if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) { |
| 425 | const branch = ref.slice(11); |
| 426 | const synced = await this.collab.syncPullsForBranch(branch, newOid, caller); |
| 427 | for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`); |
| 428 | if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) { |
| 429 | messages.push(`Create a pull request for '${branch}':`); |
| 430 | messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`); |
| 431 | } |
| 432 | } |
| 433 | if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`)); |
| 434 | // A base branch that moved (outside the queue, or by it) needs its merge groups checked, |
| 435 | // and pull requests into it waiting to auto-merge may now be up to date. |
| 436 | if (ref.startsWith("refs/heads/") && this.queue.hasEntries(ref.slice(11))) this.queue.schedule(ref.slice(11)); |
| 437 | if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) this.autoMerge.schedule({ base: ref.slice(11) }); |
| 438 | } |
| 439 | |
| 440 | // ---------------------------------------------------------------- browsing |
| 441 | |
| 442 | async commitInfo(sha: string): Promise<CommitInfo> { |
| 443 | const c = await readCommit(this.store, sha); |
| 444 | return toInfo(sha, c); |
| 445 | } |
| 446 | |
| 447 | async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> { |
| 448 | const sha = await this.resolve(rev); |
| 449 | if (!sha) return null; |
| 450 | const c = await readCommit(this.store, sha); |
| 451 | const entry = await resolvePath(this.store, c.tree, path); |
| 452 | if (!entry || !isTreeMode(entry.mode)) return null; |
| 453 | const items: TreeItem[] = []; |
| 454 | for (const e of await readTree(this.store, entry.oid)) { |
| 455 | const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob"; |
| 456 | const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined; |
| 457 | items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size }); |
| 458 | } |
| 459 | items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1)); |
| 460 | return { sha, items }; |
| 461 | } |
| 462 | |
| 463 | async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> { |
| 464 | const sha = await this.resolve(rev); |
| 465 | if (!sha) return null; |
| 466 | const c = await readCommit(this.store, sha); |
| 467 | const entry = await resolvePath(this.store, c.tree, path); |
| 468 | if (!entry || isTreeMode(entry.mode)) return null; |
| 469 | const o = await this.store.read(entry.oid); |
| 470 | if (!o) return null; |
| 471 | return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content }; |
| 472 | } |
| 473 | |
| 474 | async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> { |
| 475 | const sha = await this.resolve(rev); |
| 476 | if (!sha) return []; |
| 477 | const limit = Math.min(opts.limit ?? 30, 100); |
| 478 | const skip = opts.skip ?? 0; |
| 479 | if (!opts.path) { |
| 480 | const commits = await walkCommits(this.store, [sha], { limit: limit + skip }); |
| 481 | return commits.slice(skip).map((c) => toInfo(c.oid, c.commit)); |
| 482 | } |
| 483 | // Path history: commits whose tree entry at `path` differs from their first parent's. |
| 484 | const out: CommitInfo[] = []; |
| 485 | let skipped = 0; |
| 486 | for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) { |
| 487 | const mine = await resolvePath(this.store, c.commit.tree, opts.path); |
| 488 | const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null; |
| 489 | const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null; |
| 490 | if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue; |
| 491 | if (skipped++ < skip) continue; |
| 492 | out.push(toInfo(c.oid, c.commit)); |
| 493 | if (out.length >= limit) break; |
| 494 | } |
| 495 | return out; |
| 496 | } |
| 497 | |
| 498 | /** Diff between two commits (or a commit and its first parent). */ |
| 499 | async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) { |
| 500 | const head = await readCommit(this.store, headSha); |
| 501 | const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null; |
| 502 | const changes = await diffTrees(this.store, baseTree, head.tree); |
| 503 | const max = opts.maxFiles ?? 300; |
| 504 | const files = []; |
| 505 | for (const ch of changes.slice(0, max)) { |
| 506 | const d = await fileDiff(this.store, ch); |
| 507 | files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) }); |
| 508 | } |
| 509 | return { |
| 510 | files, |
| 511 | totalFiles: changes.length, |
| 512 | additions: files.reduce((s, f) => s + f.additions, 0), |
| 513 | deletions: files.reduce((s, f) => s + f.deletions, 0), |
| 514 | truncated: changes.length > max, |
| 515 | }; |
| 516 | } |
| 517 | |
| 518 | async commitWithDiff(rev: string) { |
| 519 | const sha = await this.resolve(rev); |
| 520 | if (!sha) throw new HttpError(404, "commit not found"); |
| 521 | const info = await this.commitInfo(sha); |
| 522 | return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) }; |
| 523 | } |
| 524 | |
| 525 | async compare(base: string, head: string) { |
| 526 | const b = await this.resolve(base); |
| 527 | const h = await this.resolve(head); |
| 528 | if (!b || !h) throw new HttpError(404, "unknown base or head"); |
| 529 | const mb = await mergeBase(this.store, b, h); |
| 530 | 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)); |
| 531 | const ahead = commits.length; |
| 532 | const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0; |
| 533 | return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) }; |
| 534 | } |
| 535 | |
| 536 | /** |
| 537 | * Push protection for commits made without a Git client: the same scan as `git push`, so the |
| 538 | * web editor, the contents and commits APIs and MCP cannot be used to slip a credential in. |
| 539 | */ |
| 540 | private protectCommit(changes: readonly FileChange[], allowSecrets: string | undefined, caller: Caller): void { |
| 541 | const blobs = changes.filter((ch) => !ch.delete && ch.content && ch.content.length < 1024 * 1024).map((ch) => ({ oid: ch.path, content: ch.content! })); |
| 542 | const leaks = scanForSecrets(blobs); |
| 543 | if (!leaks.length) return; |
| 544 | const kinds = [...new Set(leaks.map((l) => l.kind))]; |
| 545 | const paths = [...new Set(leaks.map((l) => l.oid))].slice(0, 10); |
| 546 | const target = `${this.meta.owner}/${this.meta.name}`; |
| 547 | const reason = allowSecrets?.trim().slice(0, 200); |
| 548 | if (reason && caller.role === "admin" && caller.kind === "user") { |
| 549 | this.emit({ type: "audit", action: "api.secret_bypassed", actor: caller, target, detail: { kinds, paths, reason }, at: now() }); |
| 550 | return; |
| 551 | } |
| 552 | this.emit({ type: "audit", action: "api.blocked_secret", actor: caller, target, detail: { kinds, paths }, at: now() }); |
| 553 | const hint = reason ? " Only repository admins (people, not agents) can bypass push protection." : ""; |
| 554 | throw new HttpError(422, `push protection: possible secret detected (${kinds.join(", ")}) in ${paths.join(", ")}. Remove it, or rotate it and ask an admin.${hint}`); |
| 555 | } |
| 556 | |
| 557 | /** |
| 558 | * Create a commit from file changes without a Git client (used by the web |
| 559 | * editor, the REST contents API and AI agents through MCP). |
| 560 | */ |
| 561 | async commitFiles(input: { |
| 562 | branch: string; |
| 563 | message: string; |
| 564 | changes: FileChange[]; |
| 565 | expectedHeadSha?: string; |
| 566 | createBranchFrom?: string; |
| 567 | author?: { name: string; email: string }; |
| 568 | committer?: { name: string; email: string }; |
| 569 | /** A human admin's reason for committing a detected secret anyway (like `git push -o allow-secrets=`). */ |
| 570 | allowSecrets?: string; |
| 571 | }, caller: Caller): Promise<{ sha: string; branch: string }> { |
| 572 | const ref = `refs/heads/${input.branch}`; |
| 573 | let parent = this.getRef(ref); |
| 574 | if (!parent) { |
| 575 | if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom); |
| 576 | else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`); |
| 577 | } |
| 578 | if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent }); |
| 579 | for (const ch of input.changes) { |
| 580 | const why = ch.delete ? null : unsafeTreePath(ch.path); |
| 581 | if (why) throw new HttpError(422, `invalid path ${JSON.stringify(ch.path)}: ${why}`); |
| 582 | } |
| 583 | this.protectCommit(input.changes, input.allowSecrets, caller); |
| 584 | const baseTree = parent ? (await readCommit(this.store, parent)).tree : null; |
| 585 | const tree = await applyChanges(this.store, baseTree, input.changes); |
| 586 | const ts = Math.floor(Date.now() / 1000); |
| 587 | const committer = input.committer |
| 588 | ? { ...input.committer, timestamp: ts, timezone: "+0000" } |
| 589 | : { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; |
| 590 | const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer; |
| 591 | const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : []; |
| 592 | const sha = await this.store.write( |
| 593 | "commit", |
| 594 | 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 }), |
| 595 | ); |
| 596 | const old = parent && this.getRef(ref) ? parent : ZERO_OID; |
| 597 | const check = await this.checkRefUpdate(ref, old, sha, caller); |
| 598 | if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); |
| 599 | if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry"); |
| 600 | await this.afterRefUpdate(ref, old, sha, caller); |
| 601 | return { sha, branch: input.branch }; |
| 602 | } |
| 603 | |
| 604 | async createRef(ref: string, sha: string, caller: Caller): Promise<void> { |
| 605 | if (!this.store.has(sha)) throw new HttpError(422, "object does not exist"); |
| 606 | const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller); |
| 607 | if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); |
| 608 | if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists"); |
| 609 | await this.afterRefUpdate(ref, ZERO_OID, sha, caller); |
| 610 | } |
| 611 | |
| 612 | async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> { |
| 613 | const old = this.getRef(ref); |
| 614 | if (!old) throw new HttpError(404, "reference not found"); |
| 615 | if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward"); |
| 616 | const check = await this.checkRefUpdate(ref, old, sha, caller); |
| 617 | if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); |
| 618 | if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved"); |
| 619 | await this.afterRefUpdate(ref, old, sha, caller); |
| 620 | } |
| 621 | |
| 622 | async deleteRef(ref: string, caller: Caller): Promise<void> { |
| 623 | const old = this.getRef(ref); |
| 624 | if (!old) throw new HttpError(404, "reference not found"); |
| 625 | const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller); |
| 626 | if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); |
| 627 | this.casRef(ref, old, null); |
| 628 | await this.afterRefUpdate(ref, old, ZERO_OID, caller); |
| 629 | } |
| 630 | |
| 631 | // ---------------------------------------------------------------- protections |
| 632 | |
| 633 | listProtections(): Array<{ pattern: string; rules: ProtectionRules }> { |
| 634 | 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) })); |
| 635 | } |
| 636 | |
| 637 | /** |
| 638 | * Create, replace or (with null) delete a rule. Only people who administer the repository change |
| 639 | * branch protection: never agents, never a workflow's token. Turning `allowAgentAutoMerge` or |
| 640 | * `agentAutoMergeWithoutApproval` on or off is audited on its own, since they let agents land changes. |
| 641 | */ |
| 642 | setProtection(pattern: string, rules: ProtectionRules | null, caller: Caller): void { |
| 643 | 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"); |
| 644 | if (rules?.mergeQueue) { |
| 645 | const asked = typeof rules.mergeQueue === "object" ? rules.mergeQueue.method : undefined; |
| 646 | const mergeQueue = normalizeMergeQueue(rules.mergeQueue); |
| 647 | // A queue that makes merge commits could never merge into a branch that refuses them. |
| 648 | if (rules.requireLinearHistory && mergeQueue.method === "merge") { |
| 649 | 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"); |
| 650 | mergeQueue.method = "squash"; |
| 651 | } |
| 652 | rules = { ...rules, mergeQueue }; |
| 653 | } |
| 654 | if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) }; |
| 655 | if (rules && rules.allowAgentAutoMerge !== undefined && typeof rules.allowAgentAutoMerge !== "boolean") throw new HttpError(422, "allowAgentAutoMerge must be a boolean"); |
| 656 | if (rules && rules.agentAutoMergeWithoutApproval !== undefined && typeof rules.agentAutoMergeWithoutApproval !== "boolean") throw new HttpError(422, "agentAutoMergeWithoutApproval must be a boolean"); |
| 657 | if (rules?.agentAutoMergeWithoutApproval && !rules.allowAgentAutoMerge) throw new HttpError(422, "agentAutoMergeWithoutApproval needs allowAgentAutoMerge: it only applies to what Diffinery lands for agents"); |
| 658 | if (rules && !rules.allowAgentAutoMerge) { |
| 659 | const { allowAgentAutoMerge: _, agentAutoMergeWithoutApproval: _w, ...rest } = rules; |
| 660 | rules = rest; |
| 661 | } |
| 662 | if (rules && !rules.agentAutoMergeWithoutApproval) { |
| 663 | const { agentAutoMergeWithoutApproval: _, ...rest } = rules; |
| 664 | rules = rest; |
| 665 | } |
| 666 | const before = this.sql.exec<{ rules: string }>("SELECT rules FROM protections WHERE pattern = ?", pattern).toArray()[0]; |
| 667 | const old = before ? (JSON.parse(before.rules) as ProtectionRules) : null; |
| 668 | const was = !!old?.allowAgentAutoMerge; |
| 669 | const waived = !!old?.agentAutoMergeWithoutApproval; |
| 670 | if (rules) this.sql.exec("INSERT INTO protections (pattern, rules) VALUES (?, ?) ON CONFLICT(pattern) DO UPDATE SET rules = excluded.rules", pattern, JSON.stringify(rules)); |
| 671 | else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern); |
| 672 | if (was !== !!rules?.allowAgentAutoMerge) { |
| 673 | const m = this.meta; |
| 674 | 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() }); |
| 675 | } |
| 676 | if (waived !== !!rules?.agentAutoMergeWithoutApproval) { |
| 677 | const m = this.meta; |
| 678 | 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() }); |
| 679 | } |
| 680 | // Turning a queue off (or changing its checks) affects what is queued, and what may auto-merge. |
| 681 | this.queue.schedule(); |
| 682 | this.autoMerge.schedule(); |
| 683 | } |
| 684 | |
| 685 | // ---------------------------------------------------------------- RPC passthroughs (collab + actions) |
| 686 | // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here. |
| 687 | |
| 688 | issues(...a: Parameters<Collab["list"]>) { |
| 689 | return this.collab.list(...a); |
| 690 | } |
| 691 | issue(...a: Parameters<Collab["get"]>) { |
| 692 | return this.collab.get(...a); |
| 693 | } |
| 694 | createIssue(...a: Parameters<Collab["createIssue"]>) { |
| 695 | return this.collab.createIssue(...a); |
| 696 | } |
| 697 | updateIssue(...a: Parameters<Collab["update"]>) { |
| 698 | return this.collab.update(...a); |
| 699 | } |
| 700 | setPullDraft(...a: Parameters<Collab["setDraft"]>) { |
| 701 | return this.collab.setDraft(...a); |
| 702 | } |
| 703 | addComment(...a: Parameters<Collab["addComment"]>) { |
| 704 | return this.collab.addComment(...a); |
| 705 | } |
| 706 | editComment(...a: Parameters<Collab["editComment"]>) { |
| 707 | return this.collab.editComment(...a); |
| 708 | } |
| 709 | createPull(...a: Parameters<Collab["createPull"]>) { |
| 710 | return this.collab.createPull(...a); |
| 711 | } |
| 712 | pullFiles(...a: Parameters<Collab["pullFiles"]>) { |
| 713 | return this.collab.pullFiles(...a); |
| 714 | } |
| 715 | pullCommits(...a: Parameters<Collab["pullCommits"]>) { |
| 716 | return this.collab.pullCommits(...a); |
| 717 | } |
| 718 | submitReview(...a: Parameters<Collab["submitReview"]>) { |
| 719 | return this.collab.submitReview(...a); |
| 720 | } |
| 721 | resolveThread(...a: Parameters<Collab["resolveThread"]>) { |
| 722 | return this.collab.resolveThread(...a); |
| 723 | } |
| 724 | mergeability(...a: Parameters<Collab["mergeability"]>) { |
| 725 | return this.collab.mergeability(...a); |
| 726 | } |
| 727 | mergePull(...a: Parameters<Collab["merge"]>) { |
| 728 | return this.collab.merge(...a); |
| 729 | } |
| 730 | requestReviewers(...a: Parameters<Collab["requestReviewers"]>) { |
| 731 | return this.collab.requestReviewers(...a); |
| 732 | } |
| 733 | labels(...a: Parameters<Collab["labels"]>) { |
| 734 | return this.collab.labels(...a); |
| 735 | } |
| 736 | setLabel(...a: Parameters<Collab["setLabel"]>) { |
| 737 | return this.collab.setLabel(...a); |
| 738 | } |
| 739 | checks(...a: Parameters<Collab["checks"]>) { |
| 740 | return this.collab.checks(...a); |
| 741 | } |
| 742 | upsertCheck(...a: Parameters<Collab["upsertCheck"]>) { |
| 743 | return this.collab.upsertCheck(...a); |
| 744 | } |
| 745 | webhooks(...a: Parameters<Collab["webhooks"]>) { |
| 746 | return this.collab.webhooks(...a); |
| 747 | } |
| 748 | createWebhook(...a: Parameters<Collab["createWebhook"]>) { |
| 749 | return this.collab.createWebhook(...a); |
| 750 | } |
| 751 | deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) { |
| 752 | return this.collab.deleteWebhook(...a); |
| 753 | } |
| 754 | recordDelivery(...a: Parameters<Collab["recordDelivery"]>) { |
| 755 | return this.collab.recordDelivery(...a); |
| 756 | } |
| 757 | |
| 758 | enqueuePull(...a: Parameters<MergeQueue["enqueue"]>) { |
| 759 | return this.queue.enqueue(...a); |
| 760 | } |
| 761 | dequeuePull(...a: Parameters<MergeQueue["dequeue"]>) { |
| 762 | return this.queue.dequeue(...a); |
| 763 | } |
| 764 | mergeQueue(...a: Parameters<MergeQueue["list"]>) { |
| 765 | return this.queue.list(...a); |
| 766 | } |
| 767 | mergeQueueSettings(...a: Parameters<MergeQueue["settings"]>) { |
| 768 | return this.queue.settings(...a); |
| 769 | } |
| 770 | enableAutoMerge(...a: Parameters<AutoMerge["enable"]>) { |
| 771 | return this.autoMerge.enable(...a); |
| 772 | } |
| 773 | disableAutoMerge(...a: Parameters<AutoMerge["disable"]>) { |
| 774 | return this.autoMerge.disable(...a); |
| 775 | } |
| 776 | |
| 777 | runs(...a: Parameters<Actions["listRuns"]>) { |
| 778 | return this.actions.listRuns(...a); |
| 779 | } |
| 780 | run(...a: Parameters<Actions["getRun"]>) { |
| 781 | return this.actions.getRun(...a); |
| 782 | } |
| 783 | runJobs(...a: Parameters<Actions["jobs"]>) { |
| 784 | return this.actions.jobs(...a); |
| 785 | } |
| 786 | claimJob(...a: Parameters<Actions["claimJob"]>) { |
| 787 | return this.actions.claimJob(...a); |
| 788 | } |
| 789 | jobStatus(jobId: string): string { |
| 790 | return this.actions.getJob(jobId).status; |
| 791 | } |
| 792 | job(...a: Parameters<Actions["getJob"]>) { |
| 793 | return this.actions.getJob(...a); |
| 794 | } |
| 795 | jobLogs(...a: Parameters<Actions["logs"]>) { |
| 796 | return this.actions.logs(...a); |
| 797 | } |
| 798 | cancelRun(...a: Parameters<Actions["cancel"]>) { |
| 799 | return this.actions.cancel(...a); |
| 800 | } |
| 801 | rerun(...a: Parameters<Actions["rerun"]>) { |
| 802 | return this.actions.rerun(...a); |
| 803 | } |
| 804 | dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) { |
| 805 | return this.actions.dispatch(...a); |
| 806 | } |
| 807 | scheduleTick(...a: Parameters<Actions["schedule"]>) { |
| 808 | return this.actions.schedule(...a); |
| 809 | } |
| 810 | onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) { |
| 811 | return this.actions.onPullRequest(...a); |
| 812 | } |
| 813 | workflows(...a: Parameters<Actions["listWorkflows"]>) { |
| 814 | return this.actions.listWorkflows(...a); |
| 815 | } |
| 816 | runPlan(...a: Parameters<Actions["plan"]>) { |
| 817 | return this.actions.plan(...a); |
| 818 | } |
| 819 | startJobKey(...a: Parameters<Actions["startJobKey"]>) { |
| 820 | return this.actions.startJobKey(...a); |
| 821 | } |
| 822 | jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) { |
| 823 | return this.actions.jobTimedOut(...a); |
| 824 | } |
| 825 | finishRun(...a: Parameters<Actions["finishRun"]>) { |
| 826 | return this.actions.finishRun(...a); |
| 827 | } |
| 828 | oidcClaims(...a: Parameters<Actions["oidcClaims"]>) { |
| 829 | return this.actions.oidcClaims(...a); |
| 830 | } |
| 831 | runnerSpec(...a: Parameters<Actions["runnerSpec"]>) { |
| 832 | return this.actions.runnerSpec(...a); |
| 833 | } |
| 834 | runnerLogs(...a: Parameters<Actions["runnerLogs"]>) { |
| 835 | return this.actions.runnerLogs(...a); |
| 836 | } |
| 837 | runnerStep(...a: Parameters<Actions["runnerStep"]>) { |
| 838 | return this.actions.runnerStep(...a); |
| 839 | } |
| 840 | runnerComplete(...a: Parameters<Actions["runnerComplete"]>) { |
| 841 | return this.actions.runnerComplete(...a); |
| 842 | } |
| 843 | packagesPermission(...a: Parameters<Actions["packagesPermission"]>) { |
| 844 | return this.actions.packagesPermission(...a); |
| 845 | } |
| 846 | runnerAuth(...a: Parameters<Actions["runnerAuth"]>) { |
| 847 | return this.actions.runnerAuth(...a); |
| 848 | } |
| 849 | containerStopped(...a: Parameters<Actions["containerStopped"]>) { |
| 850 | return this.actions.containerStopped(...a); |
| 851 | } |
| 852 | setSecret(...a: Parameters<Actions["setSecret"]>) { |
| 853 | return this.actions.setSecret(...a); |
| 854 | } |
| 855 | deleteSecret(...a: Parameters<Actions["deleteSecret"]>) { |
| 856 | return this.actions.deleteSecret(...a); |
| 857 | } |
| 858 | secretNames(...a: Parameters<Actions["secretNames"]>) { |
| 859 | return this.actions.secretNames(...a); |
| 860 | } |
| 861 | setVariable(...a: Parameters<Actions["setVariable"]>) { |
| 862 | return this.actions.setVariable(...a); |
| 863 | } |
| 864 | variables(...a: Parameters<Actions["variables"]>) { |
| 865 | return this.actions.variables(...a); |
| 866 | } |
| 867 | cacheLookup(...a: Parameters<Actions["cacheLookup"]>) { |
| 868 | return this.actions.cacheLookup(...a); |
| 869 | } |
| 870 | cacheSaved(...a: Parameters<Actions["cacheSaved"]>) { |
| 871 | return this.actions.cacheSaved(...a); |
| 872 | } |
| 873 | artifactSaved(...a: Parameters<Actions["artifactSaved"]>) { |
| 874 | return this.actions.artifactSaved(...a); |
| 875 | } |
| 876 | artifacts(...a: Parameters<Actions["artifacts"]>) { |
| 877 | return this.actions.artifacts(...a); |
| 878 | } |
| 879 | createSandbox(...a: Parameters<Actions["createSandbox"]>) { |
| 880 | return this.actions.createSandbox(...a); |
| 881 | } |
| 882 | sandbox(...a: Parameters<Actions["getSandbox"]>) { |
| 883 | return this.actions.getSandbox(...a); |
| 884 | } |
| 885 | closeSandbox(...a: Parameters<Actions["closeSandbox"]>) { |
| 886 | return this.actions.closeSandbox(...a); |
| 887 | } |
| 888 | |
| 889 | plannedRunError(runId: string, message: string) { |
| 890 | return this.actions.plannedError(runId, message); |
| 891 | } |
| 892 | cacheSaveKeys(...a: Parameters<Actions["cacheSaveKeys"]>) { |
| 893 | return this.actions.cacheSaveKeys(...a); |
| 894 | } |
| 895 | cacheReadKeys(...a: Parameters<Actions["cacheReadKeys"]>) { |
| 896 | return this.actions.cacheReadKeys(...a); |
| 897 | } |
| 898 | artifactKeysFor(...a: Parameters<Actions["artifactKeysFor"]>) { |
| 899 | return this.actions.artifactKeysFor(...a); |
| 900 | } |
| 901 | artifactQuota() { |
| 902 | return this.runtime.artifactQuota; |
| 903 | } |
| 904 | |
| 905 | // ---------------------------------------------------------------- RPC passthroughs (Actions runtime services) |
| 906 | // The cache and artifact services of repo/runtime.ts, called by http/actions-runtime.ts |
| 907 | // with the job named by a runtime token, and by the REST caches/artifacts routes. |
| 908 | |
| 909 | rtJob(...a: Parameters<Runtime["job"]>) { |
| 910 | return this.runtime.job(...a); |
| 911 | } |
| 912 | rtGrantFor(...a: Parameters<Runtime["grantFor"]>) { |
| 913 | return this.runtime.grantFor(...a); |
| 914 | } |
| 915 | rtCacheLookup(...a: Parameters<Runtime["cacheLookup"]>) { |
| 916 | return this.runtime.cacheLookup(...a); |
| 917 | } |
| 918 | rtCacheReserve(...a: Parameters<Runtime["cacheReserve"]>) { |
| 919 | return this.runtime.cacheReserve(...a); |
| 920 | } |
| 921 | rtCacheReservation(...a: Parameters<Runtime["cacheReservation"]>) { |
| 922 | return this.runtime.cacheReservation(...a); |
| 923 | } |
| 924 | rtCacheWritable(...a: Parameters<Runtime["cacheWritable"]>) { |
| 925 | return this.runtime.cacheWritable(...a); |
| 926 | } |
| 927 | rtCacheCommit(...a: Parameters<Runtime["cacheCommit"]>) { |
| 928 | return this.runtime.cacheCommit(...a); |
| 929 | } |
| 930 | rtCacheGet(...a: Parameters<Runtime["cacheGet"]>) { |
| 931 | return this.runtime.cacheGet(...a); |
| 932 | } |
| 933 | rtListCaches(...a: Parameters<Runtime["listCaches"]>) { |
| 934 | return this.runtime.listCaches(...a); |
| 935 | } |
| 936 | rtDeleteCaches(...a: Parameters<Runtime["deleteCaches"]>) { |
| 937 | return this.runtime.deleteCaches(...a); |
| 938 | } |
| 939 | rtCacheUsage(...a: Parameters<Runtime["cacheUsage"]>) { |
| 940 | return this.runtime.cacheUsage(...a); |
| 941 | } |
| 942 | rtArtifactCreate(...a: Parameters<Runtime["artifactCreate"]>) { |
| 943 | return this.runtime.artifactCreate(...a); |
| 944 | } |
| 945 | rtArtifactUpload(...a: Parameters<Runtime["artifactUpload"]>) { |
| 946 | return this.runtime.artifactUpload(...a); |
| 947 | } |
| 948 | rtArtifactWritable(...a: Parameters<Runtime["artifactWritable"]>) { |
| 949 | return this.runtime.artifactWritable(...a); |
| 950 | } |
| 951 | rtArtifactFinalize(...a: Parameters<Runtime["artifactFinalize"]>) { |
| 952 | return this.runtime.artifactFinalize(...a); |
| 953 | } |
| 954 | rtArtifactGet(...a: Parameters<Runtime["artifactGet"]>) { |
| 955 | return this.runtime.artifactGet(...a); |
| 956 | } |
| 957 | rtJobArtifacts(...a: Parameters<Runtime["jobArtifacts"]>) { |
| 958 | return this.runtime.jobArtifacts(...a); |
| 959 | } |
| 960 | rtRunArtifacts(...a: Parameters<Runtime["runArtifacts"]>) { |
| 961 | return this.runtime.runArtifacts(...a); |
| 962 | } |
| 963 | rtRepoArtifacts(...a: Parameters<Runtime["repoArtifacts"]>) { |
| 964 | return this.runtime.repoArtifacts(...a); |
| 965 | } |
| 966 | rtArtifactDelete(...a: Parameters<Runtime["artifactDelete"]>) { |
| 967 | return this.runtime.artifactDelete(...a); |
| 968 | } |
| 969 | rtArtifactDeleteByName(...a: Parameters<Runtime["artifactDeleteByName"]>) { |
| 970 | return this.runtime.artifactDeleteByName(...a); |
| 971 | } |
| 972 | workflowSettings() { |
| 973 | return this.policy.settings(); |
| 974 | } |
| 975 | setWorkflowSettings(...a: Parameters<TokenPolicy["setSettings"]>) { |
| 976 | return this.policy.setSettings(...a); |
| 977 | } |
| 978 | rtStage(...a: Parameters<Runtime["stage"]>) { |
| 979 | return this.runtime.stage(...a); |
| 980 | } |
| 981 | rtUnstage(...a: Parameters<Runtime["unstage"]>) { |
| 982 | return this.runtime.unstage(...a); |
| 983 | } |
| 984 | artifactRoom() { |
| 985 | return this.runtime.artifactRoom(); |
| 986 | } |
| 987 | rtCheckCommitSize(...a: Parameters<Runtime["checkCommitSize"]>) { |
| 988 | return this.runtime.checkCommitSize(...a); |
| 989 | } |
| 990 | rtCacheMissing(...a: Parameters<Runtime["cacheMissing"]>) { |
| 991 | return this.runtime.cacheMissing(...a); |
| 992 | } |
| 993 | rtComposeStep(...a: Parameters<Runtime["composeStep"]>) { |
| 994 | return this.runtime.composeStep(...a); |
| 995 | } |
| 996 | |
| 997 | // ---------------------------------------------------------------- RPC passthroughs (GitHub-compatible extras) |
| 998 | // Releases, commit statuses, deployments and environments, issue sub-resources |
| 999 | // (labels, reactions, events, locking), the Git data API, search and misc. |
| 1000 | // The modules are created on first use. |
| 1001 | |
| 1002 | private extras: { releases?: Releases; statuses?: Statuses; deploys?: Deployments; social?: Social; gitdata?: GitData } = {}; |
| 1003 | get releases(): Releases { |
| 1004 | return (this.extras.releases ??= new Releases(this, this.env)); |
| 1005 | } |
| 1006 | get statuses(): Statuses { |
| 1007 | return (this.extras.statuses ??= new Statuses(this)); |
| 1008 | } |
| 1009 | get deploys(): Deployments { |
| 1010 | return (this.extras.deploys ??= new Deployments(this, this.env)); |
| 1011 | } |
| 1012 | get social(): Social { |
| 1013 | return (this.extras.social ??= new Social(this)); |
| 1014 | } |
| 1015 | get gitdata(): GitData { |
| 1016 | return (this.extras.gitdata ??= new GitData(this, this.env)); |
| 1017 | } |
| 1018 | |
| 1019 | listReleases(...a: Parameters<Releases["list"]>) { |
| 1020 | return this.releases.list(...a); |
| 1021 | } |
| 1022 | getRelease(...a: Parameters<Releases["get"]>) { |
| 1023 | return this.releases.get(...a); |
| 1024 | } |
| 1025 | releaseByTag(...a: Parameters<Releases["byTag"]>) { |
| 1026 | return this.releases.byTag(...a); |
| 1027 | } |
| 1028 | latestRelease() { |
| 1029 | return this.releases.latest(); |
| 1030 | } |
| 1031 | releaseSummary(...a: Parameters<Releases["summary"]>) { |
| 1032 | return this.releases.summary(...a); |
| 1033 | } |
| 1034 | createRelease(...a: Parameters<Releases["create"]>) { |
| 1035 | return this.releases.create(...a); |
| 1036 | } |
| 1037 | updateRelease(...a: Parameters<Releases["update"]>) { |
| 1038 | return this.releases.update(...a); |
| 1039 | } |
| 1040 | deleteRelease(...a: Parameters<Releases["remove"]>) { |
| 1041 | return this.releases.remove(...a); |
| 1042 | } |
| 1043 | generateReleaseNotes(...a: Parameters<Releases["generateNotes"]>) { |
| 1044 | return this.releases.generateNotes(...a); |
| 1045 | } |
| 1046 | releaseAssets(...a: Parameters<Releases["assets"]>) { |
| 1047 | return this.releases.assets(...a); |
| 1048 | } |
| 1049 | releaseAsset(...a: Parameters<Releases["asset"]>) { |
| 1050 | return this.releases.asset(...a); |
| 1051 | } |
| 1052 | updateReleaseAsset(...a: Parameters<Releases["updateAsset"]>) { |
| 1053 | return this.releases.updateAsset(...a); |
| 1054 | } |
| 1055 | deleteReleaseAsset(...a: Parameters<Releases["deleteAsset"]>) { |
| 1056 | return this.releases.deleteAsset(...a); |
| 1057 | } |
| 1058 | startReleaseAsset(...a: Parameters<Releases["startAsset"]>) { |
| 1059 | return this.releases.startAsset(...a); |
| 1060 | } |
| 1061 | finishReleaseAsset(...a: Parameters<Releases["finishAsset"]>) { |
| 1062 | return this.releases.finishAsset(...a); |
| 1063 | } |
| 1064 | abortReleaseAsset(...a: Parameters<Releases["abortAsset"]>) { |
| 1065 | return this.releases.abortAsset(...a); |
| 1066 | } |
| 1067 | releaseDownload(...a: Parameters<Releases["download"]>) { |
| 1068 | return this.releases.download(...a); |
| 1069 | } |
| 1070 | /** For the GitHub importer: a release with its original dates and author (no tag, events or workflows). */ |
| 1071 | importRelease(...a: Parameters<Releases["importRelease"]>) { |
| 1072 | return this.releases.importRelease(...a); |
| 1073 | } |
| 1074 | /** For the GitHub importer: an asset copied from a public https URL, or from an R2 key under this repository's prefix. */ |
| 1075 | importReleaseAsset(...a: Parameters<Releases["importAsset"]>) { |
| 1076 | return this.releases.importAsset(...a); |
| 1077 | } |
| 1078 | |
| 1079 | createStatus(...a: Parameters<Statuses["create"]>) { |
| 1080 | return this.statuses.create(...a); |
| 1081 | } |
| 1082 | commitStatuses(...a: Parameters<Statuses["list"]>) { |
| 1083 | return this.statuses.list(...a); |
| 1084 | } |
| 1085 | combinedStatus(...a: Parameters<Statuses["combined"]>) { |
| 1086 | return this.statuses.combined(...a); |
| 1087 | } |
| 1088 | /** Check runs and commit statuses together, as pull request and commit pages show them. */ |
| 1089 | checksAndStatuses(sha: string) { |
| 1090 | return [...this.collab.checks(sha), ...this.statuses.asChecks(sha)]; |
| 1091 | } |
| 1092 | |
| 1093 | createDeployment(...a: Parameters<Deployments["create"]>) { |
| 1094 | return this.deploys.create(...a); |
| 1095 | } |
| 1096 | deployments(...a: Parameters<Deployments["list"]>) { |
| 1097 | return this.deploys.list(...a); |
| 1098 | } |
| 1099 | deployment(...a: Parameters<Deployments["get"]>) { |
| 1100 | return this.deploys.get(...a); |
| 1101 | } |
| 1102 | deleteDeployment(...a: Parameters<Deployments["remove"]>) { |
| 1103 | return this.deploys.remove(...a); |
| 1104 | } |
| 1105 | createDeploymentStatus(...a: Parameters<Deployments["createStatus"]>) { |
| 1106 | return this.deploys.createStatus(...a); |
| 1107 | } |
| 1108 | deploymentStatuses(...a: Parameters<Deployments["statuses"]>) { |
| 1109 | return this.deploys.statuses(...a); |
| 1110 | } |
| 1111 | deploymentStatus(...a: Parameters<Deployments["status"]>) { |
| 1112 | return this.deploys.status(...a); |
| 1113 | } |
| 1114 | environments() { |
| 1115 | return this.deploys.environments(); |
| 1116 | } |
| 1117 | environment(...a: Parameters<Deployments["environment"]>) { |
| 1118 | return this.deploys.environment(...a); |
| 1119 | } |
| 1120 | upsertEnvironment(...a: Parameters<Deployments["upsertEnvironment"]>) { |
| 1121 | return this.deploys.upsertEnvironment(...a); |
| 1122 | } |
| 1123 | deleteEnvironment(...a: Parameters<Deployments["deleteEnvironment"]>) { |
| 1124 | return this.deploys.deleteEnvironment(...a); |
| 1125 | } |
| 1126 | addEnvironmentBranchPattern(...a: Parameters<Deployments["addBranchPattern"]>) { |
| 1127 | return this.deploys.addBranchPattern(...a); |
| 1128 | } |
| 1129 | removeEnvironmentBranchPattern(...a: Parameters<Deployments["removeBranchPattern"]>) { |
| 1130 | return this.deploys.removeBranchPattern(...a); |
| 1131 | } |
| 1132 | setEnvironmentSecret(...a: Parameters<Deployments["setSecret"]>) { |
| 1133 | return this.deploys.setSecret(...a); |
| 1134 | } |
| 1135 | deleteEnvironmentSecret(...a: Parameters<Deployments["deleteSecret"]>) { |
| 1136 | return this.deploys.deleteSecret(...a); |
| 1137 | } |
| 1138 | environmentSecretNames(...a: Parameters<Deployments["secretNames"]>) { |
| 1139 | return this.deploys.secretNames(...a); |
| 1140 | } |
| 1141 | setEnvironmentVariable(...a: Parameters<Deployments["setVariable"]>) { |
| 1142 | return this.deploys.setVariable(...a); |
| 1143 | } |
| 1144 | environmentVariables(...a: Parameters<Deployments["variables"]>) { |
| 1145 | return this.deploys.variables(...a); |
| 1146 | } |
| 1147 | |
| 1148 | /** What the GitHub JSON builders need for a set of issues: label rows, reaction counts and lock reasons. */ |
| 1149 | issueExtras(numbers: number[], subjects: string[] = []) { |
| 1150 | const lockReasons: Record<number, string | null> = {}; |
| 1151 | for (const n of numbers) lockReasons[n] = this.social.lockReason(n); |
| 1152 | return { labels: this.social.labelRows(), reactions: this.social.reactionCounts([...numbers.map((n) => `issue:${n}`), ...subjects]), lockReasons }; |
| 1153 | } |
| 1154 | issueEvents(...a: Parameters<Social["events"]>) { |
| 1155 | return this.social.events(...a); |
| 1156 | } |
| 1157 | repoIssueEvents(...a: Parameters<Social["repoEvents"]>) { |
| 1158 | return this.social.repoEvents(...a); |
| 1159 | } |
| 1160 | issueEvent(...a: Parameters<Social["event"]>) { |
| 1161 | return this.social.event(...a); |
| 1162 | } |
| 1163 | issueTimeline(...a: Parameters<Social["timeline"]>) { |
| 1164 | return this.social.timeline(...a); |
| 1165 | } |
| 1166 | labelRows() { |
| 1167 | return this.social.labelRows(); |
| 1168 | } |
| 1169 | label(...a: Parameters<Social["label"]>) { |
| 1170 | return this.social.label(...a); |
| 1171 | } |
| 1172 | createLabel(...a: Parameters<Social["createLabel"]>) { |
| 1173 | return this.social.createLabel(...a); |
| 1174 | } |
| 1175 | updateLabel(...a: Parameters<Social["updateLabel"]>) { |
| 1176 | return this.social.updateLabel(...a); |
| 1177 | } |
| 1178 | deleteLabel(...a: Parameters<Social["deleteLabel"]>) { |
| 1179 | return this.social.deleteLabel(...a); |
| 1180 | } |
| 1181 | issueLabels(...a: Parameters<Social["issueLabels"]>) { |
| 1182 | return this.social.issueLabels(...a); |
| 1183 | } |
| 1184 | addIssueLabels(...a: Parameters<Social["addLabels"]>) { |
| 1185 | return this.social.addLabels(...a); |
| 1186 | } |
| 1187 | setIssueLabels(...a: Parameters<Social["setLabels"]>) { |
| 1188 | return this.social.setLabels(...a); |
| 1189 | } |
| 1190 | removeIssueLabel(...a: Parameters<Social["removeLabel"]>) { |
| 1191 | return this.social.removeLabel(...a); |
| 1192 | } |
| 1193 | assignIssue(...a: Parameters<Social["assign"]>) { |
| 1194 | return this.social.assign(...a); |
| 1195 | } |
| 1196 | lockIssue(...a: Parameters<Social["lock"]>) { |
| 1197 | return this.social.lock(...a); |
| 1198 | } |
| 1199 | unlockIssue(...a: Parameters<Social["unlock"]>) { |
| 1200 | return this.social.unlock(...a); |
| 1201 | } |
| 1202 | comment(...a: Parameters<Social["comment"]>) { |
| 1203 | return this.social.comment(...a); |
| 1204 | } |
| 1205 | deleteComment(...a: Parameters<Social["deleteComment"]>) { |
| 1206 | return this.social.deleteComment(...a); |
| 1207 | } |
| 1208 | repoComments(...a: Parameters<Social["repoComments"]>) { |
| 1209 | return this.social.repoComments(...a); |
| 1210 | } |
| 1211 | createReviewComment(...a: Parameters<Social["createReviewComment"]>) { |
| 1212 | return this.social.createReviewComment(...a); |
| 1213 | } |
| 1214 | review(...a: Parameters<Social["review"]>) { |
| 1215 | return this.social.review(...a); |
| 1216 | } |
| 1217 | dismissReview(...a: Parameters<Social["dismissReview"]>) { |
| 1218 | return this.social.dismissReview(...a); |
| 1219 | } |
| 1220 | removeReviewers(...a: Parameters<Social["removeReviewers"]>) { |
| 1221 | return this.social.removeReviewers(...a); |
| 1222 | } |
| 1223 | updatePullBranch(...a: Parameters<Social["updateBranch"]>) { |
| 1224 | return this.social.updateBranch(...a); |
| 1225 | } |
| 1226 | pullsForCommit(...a: Parameters<Social["pullsForCommit"]>) { |
| 1227 | return this.social.pullsForCommit(...a); |
| 1228 | } |
| 1229 | reactions(...a: Parameters<Social["reactions"]>) { |
| 1230 | return this.social.reactions(...a); |
| 1231 | } |
| 1232 | react(...a: Parameters<Social["react"]>) { |
| 1233 | return this.social.react(...a); |
| 1234 | } |
| 1235 | unreact(...a: Parameters<Social["unreact"]>) { |
| 1236 | return this.social.unreact(...a); |
| 1237 | } |
| 1238 | searchIssues(query: Parameters<typeof searchIssues>[1], limit: number) { |
| 1239 | return searchIssues(this, query, limit); |
| 1240 | } |
| 1241 | /** The issues for one page of search hits, in the order given (missing numbers are skipped). */ |
| 1242 | issuesByNumber(numbers: number[]) { |
| 1243 | return numbers.slice(0, 100).flatMap((n) => { |
| 1244 | try { |
| 1245 | return [this.collab.get(n).issue]; |
| 1246 | } catch { |
| 1247 | return []; |
| 1248 | } |
| 1249 | }); |
| 1250 | } |
| 1251 | |
| 1252 | objectType(...a: Parameters<GitData["objectType"]>) { |
| 1253 | return this.gitdata.objectType(...a); |
| 1254 | } |
| 1255 | refsWithTypes(...a: Parameters<GitData["refsWithTypes"]>) { |
| 1256 | return this.gitdata.refsWithTypes(...a); |
| 1257 | } |
| 1258 | createBlob(...a: Parameters<GitData["createBlob"]>) { |
| 1259 | return this.gitdata.createBlob(...a); |
| 1260 | } |
| 1261 | getBlob(...a: Parameters<GitData["getBlob"]>) { |
| 1262 | return this.gitdata.getBlob(...a); |
| 1263 | } |
| 1264 | createTree(...a: Parameters<GitData["createTree"]>) { |
| 1265 | return this.gitdata.createTree(...a); |
| 1266 | } |
| 1267 | getTree(...a: Parameters<GitData["getTree"]>) { |
| 1268 | return this.gitdata.getTree(...a); |
| 1269 | } |
| 1270 | createGitCommit(...a: Parameters<GitData["createCommit"]>) { |
| 1271 | return this.gitdata.createCommit(...a); |
| 1272 | } |
| 1273 | gitCommit(...a: Parameters<GitData["gitCommit"]>) { |
| 1274 | return this.gitdata.gitCommit(...a); |
| 1275 | } |
| 1276 | createGitTag(...a: Parameters<GitData["createTag"]>) { |
| 1277 | return this.gitdata.createTag(...a); |
| 1278 | } |
| 1279 | gitTag(...a: Parameters<GitData["getTag"]>) { |
| 1280 | return this.gitdata.getTag(...a); |
| 1281 | } |
| 1282 | putContents(...a: Parameters<GitData["putContents"]>) { |
| 1283 | return this.gitdata.putContents(...a); |
| 1284 | } |
| 1285 | deleteContents(...a: Parameters<GitData["deleteContents"]>) { |
| 1286 | return this.gitdata.deleteContents(...a); |
| 1287 | } |
| 1288 | readme(...a: Parameters<GitData["readme"]>) { |
| 1289 | return this.gitdata.readme(...a); |
| 1290 | } |
| 1291 | mergeBranches(...a: Parameters<GitData["merge"]>) { |
| 1292 | return this.gitdata.merge(...a); |
| 1293 | } |
| 1294 | languages() { |
| 1295 | return this.gitdata.languages(); |
| 1296 | } |
| 1297 | archiveKey(...a: Parameters<GitData["archiveKey"]>) { |
| 1298 | return this.gitdata.archiveKey(...a); |
| 1299 | } |
| 1300 | buildArchive(...a: Parameters<GitData["buildArchive"]>) { |
| 1301 | return this.gitdata.buildArchive(...a); |
| 1302 | } |
| 1303 | runLogsZip(...a: Parameters<GitData["runLogsZip"]>) { |
| 1304 | return this.gitdata.runLogsZip(...a); |
| 1305 | } |
| 1306 | runAttemptId(...a: Parameters<GitData["runAttemptId"]>) { |
| 1307 | return this.gitdata.runAttemptId(...a); |
| 1308 | } |
| 1309 | checkRun(...a: Parameters<GitData["check"]>) { |
| 1310 | return this.gitdata.check(...a); |
| 1311 | } |
| 1312 | updateCheckRun(...a: Parameters<GitData["updateCheck"]>) { |
| 1313 | return this.gitdata.updateCheck(...a); |
| 1314 | } |
| 1315 | checkSuites(...a: Parameters<GitData["checkSuites"]>) { |
| 1316 | return this.gitdata.checkSuites(...a); |
| 1317 | } |
| 1318 | checkSuite(...a: Parameters<GitData["checkSuite"]>) { |
| 1319 | return this.gitdata.checkSuite(...a); |
| 1320 | } |
| 1321 | |
| 1322 | /** repository_dispatch: an external event that starts workflows listening for its `event_type`. */ |
| 1323 | async repositoryDispatch(eventType: string, clientPayload: Record<string, unknown>, caller: Caller): Promise<void> { |
| 1324 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to dispatch events"); |
| 1325 | const sha = this.getRef(this.headRef); |
| 1326 | if (!sha) throw new HttpError(422, "the repository is empty"); |
| 1327 | const m = this.meta; |
| 1328 | const payload = { action: eventType, branch: m.defaultBranch, client_payload: clientPayload }; |
| 1329 | 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() }); |
| 1330 | await triggerWorkflows(this, { name: "repository_dispatch", action: eventType, ref: this.headRef, sha, payload, title: eventType }, caller); |
| 1331 | } |
| 1332 | |
| 1333 | // ---------------------------------------------------------------- RPC passthroughs: GitHub imports and milestones (repo/imports.ts) |
| 1334 | |
| 1335 | private importsCache?: RepoImports; |
| 1336 | private get importsModule(): RepoImports { |
| 1337 | return (this.importsCache ??= new RepoImports(this.sql)); |
| 1338 | } |
| 1339 | |
| 1340 | /** The import that currently holds this repository, or null. Other writes are refused meanwhile (loadRepo, gitReceivePack). */ |
| 1341 | importLockHolder(): string | null { |
| 1342 | return this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'import_lock'").toArray()[0]?.value ?? null; |
| 1343 | } |
| 1344 | |
| 1345 | /** |
| 1346 | * Claim the repository for an import. Refuses if another import holds it, or if the repo |
| 1347 | * already holds any ref, issue, comment, milestone, protection or release this import did not write |
| 1348 | * (so a retry never adopts content people added in the meantime — H2). |
| 1349 | */ |
| 1350 | importBegin(importId: string): void { |
| 1351 | const holder = this.importLockHolder(); |
| 1352 | if (holder && holder !== importId) throw new HttpError(409, "another import is in progress for this repository"); |
| 1353 | // A ref not written by this import, or any foreign row, means the repo was adopted since. |
| 1354 | for (const r of this.refs()) { |
| 1355 | if (r.name.startsWith("refs/diffinery-import/")) continue; |
| 1356 | if (this.importsModule.importRefOid(r.name, importId) === null) { |
| 1357 | if (holder !== importId) throw new HttpError(409, "repository already holds content not written by this import; start a new import"); |
| 1358 | } |
| 1359 | } |
| 1360 | if (this.importsModule.hasForeignContent(importId)) throw new HttpError(409, "repository already holds content not written by this import; start a new import"); |
| 1361 | this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('import_lock', ?)", importId); |
| 1362 | } |
| 1363 | |
| 1364 | /** Release the import lock (on finalize, fail or cancel). */ |
| 1365 | importEnd(importId: string): void { |
| 1366 | if (this.importLockHolder() === importId) this.sql.exec("DELETE FROM meta WHERE key = 'import_lock'"); |
| 1367 | } |
| 1368 | |
| 1369 | importProtection(pattern: string, rules: ProtectionRules | null, importId: string) { |
| 1370 | // What agents may do is a person's decision here, never something an import brings in. |
| 1371 | if (rules) { |
| 1372 | const { allowAgentAutoMerge: _a, agentAutoMergeWithoutApproval: _w, agentsMayPush: _p, agentsMayMerge: _m, ...rest } = rules; |
| 1373 | rules = rest; |
| 1374 | } |
| 1375 | if (rules?.mergeQueue) rules = { ...rules, mergeQueue: normalizeMergeQueue(rules.mergeQueue) }; |
| 1376 | if (rules?.requiredCheckSources) rules = { ...rules, requiredCheckSources: normalizeCheckSources(rules.requiredCheckSources) }; |
| 1377 | if (rules) this.importsModule.protection(pattern, JSON.stringify(rules), importId); |
| 1378 | } |
| 1379 | importLabels(...a: Parameters<RepoImports["labels"]>) { |
| 1380 | return this.importsModule.labels(...a); |
| 1381 | } |
| 1382 | importMilestones(...a: Parameters<RepoImports["milestones"]>) { |
| 1383 | return this.importsModule.milestones(...a); |
| 1384 | } |
| 1385 | importIssues(...a: Parameters<RepoImports["issues"]>) { |
| 1386 | return this.importsModule.issues(...a); |
| 1387 | } |
| 1388 | importComments(...a: Parameters<RepoImports["comments"]>) { |
| 1389 | return this.importsModule.comments(...a); |
| 1390 | } |
| 1391 | importReviews(...a: Parameters<RepoImports["reviews"]>) { |
| 1392 | return this.importsModule.reviews(...a); |
| 1393 | } |
| 1394 | importSetMergedBy(...a: Parameters<RepoImports["setMergedBy"]>) { |
| 1395 | return this.importsModule.setMergedBy(...a); |
| 1396 | } |
| 1397 | importPullNumbers(...a: Parameters<RepoImports["pullNumbers"]>) { |
| 1398 | return this.importsModule.pullNumbers(...a); |
| 1399 | } |
| 1400 | importCounts() { |
| 1401 | return this.importsModule.counts(); |
| 1402 | } |
| 1403 | importFindings() { |
| 1404 | return this.importsModule.findings(); |
| 1405 | } |
| 1406 | /** |
| 1407 | * Point `refs/pull/N/head` at each imported pull request's head commit (the server owns |
| 1408 | * refs/pull/*; the agent pushed the commits to refs/diffinery-import/pull/N). Heads whose |
| 1409 | * commits never arrived (e.g. deleted forks) are reported, not fatal. |
| 1410 | */ |
| 1411 | importPullRefs(importId?: string): { created: number; missing: number[] } { |
| 1412 | const missing: number[] = []; |
| 1413 | let created = 0; |
| 1414 | for (const p of this.importsModule.pullHeads()) { |
| 1415 | const ref = `refs/pull/${p.number}/head`; |
| 1416 | if (!/^[0-9a-f]{40}$/.test(p.headSha) || !this.store.has(p.headSha)) { |
| 1417 | missing.push(p.number); |
| 1418 | continue; |
| 1419 | } |
| 1420 | const current = this.getRef(ref); |
| 1421 | // Record provenance so a retry does not mistake refs/pull/*/head for content people adopted. |
| 1422 | if (importId) this.importsModule.recordImportRef(ref, p.headSha, importId); |
| 1423 | if (current === p.headSha) continue; |
| 1424 | if (this.casRef(ref, current, p.headSha)) created++; |
| 1425 | } |
| 1426 | return { created, missing }; |
| 1427 | } |
| 1428 | /** Remove the import's own refs once refs/pull/N/head keep the pull request commits reachable. */ |
| 1429 | importDropTempRefs(importId?: string): number { |
| 1430 | const refs = this.refs(IMPORT_REF_PREFIX); |
| 1431 | for (const r of refs) this.casRef(r.name, r.oid, null); |
| 1432 | if (importId) this.importsModule.dropImportRefs(importId); |
| 1433 | return refs.length; |
| 1434 | } |
| 1435 | setOrgVariables(...a: Parameters<RepoImports["setOrgVariables"]>) { |
| 1436 | return this.importsModule.setOrgVariables(...a); |
| 1437 | } |
| 1438 | issueMilestoneOf(number: number) { |
| 1439 | return this.importsModule.milestoneOf(number); |
| 1440 | } |
| 1441 | /** Check names that workflow jobs on the default branch report, keyed by job name (GitHub names checks by job). */ |
| 1442 | async importCheckNames(): Promise<Record<string, string[]>> { |
| 1443 | const sha = await this.resolve("HEAD"); |
| 1444 | const out: Record<string, string[]> = {}; |
| 1445 | if (!sha) return out; |
| 1446 | for (const w of await this.actions.loadWorkflows(sha)) { |
| 1447 | if (!w.workflow) continue; |
| 1448 | const wf = w.workflow.name ?? w.path.split("/").pop()!; |
| 1449 | for (const [key, job] of Object.entries(w.workflow.jobs)) { |
| 1450 | const name = (job as { name?: unknown }).name; |
| 1451 | for (const n of new Set([key, typeof name === "string" ? name : key])) (out[n] ??= []).push(`${wf} / ${typeof name === "string" && !name.includes("${{") ? name : key}`); |
| 1452 | } |
| 1453 | } |
| 1454 | return out; |
| 1455 | } |
| 1456 | milestones(...a: Parameters<RepoImports["listMilestones"]>) { |
| 1457 | return this.importsModule.listMilestones(...a); |
| 1458 | } |
| 1459 | milestone(...a: Parameters<RepoImports["milestone"]>) { |
| 1460 | return this.importsModule.milestone(...a); |
| 1461 | } |
| 1462 | saveMilestone(...a: Parameters<RepoImports["saveMilestone"]>) { |
| 1463 | return this.importsModule.saveMilestone(...a); |
| 1464 | } |
| 1465 | setIssueMilestone(...a: Parameters<RepoImports["setIssueMilestone"]>) { |
| 1466 | return this.importsModule.setIssueMilestone(...a); |
| 1467 | } |
| 1468 | |
| 1469 | // ---------------------------------------------------------------- live updates (WebSockets with hibernation) |
| 1470 | |
| 1471 | override async fetch(request: Request): Promise<Response> { |
| 1472 | if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 }); |
| 1473 | const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10); |
| 1474 | const pair = new WebSocketPair(); |
| 1475 | this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]); |
| 1476 | return new Response(null, { status: 101, webSocket: pair[0] }); |
| 1477 | } |
| 1478 | |
| 1479 | override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { |
| 1480 | if (message === "ping") ws.send("pong"); |
| 1481 | } |
| 1482 | |
| 1483 | override async webSocketClose(ws: WebSocket, code: number): Promise<void> { |
| 1484 | try { |
| 1485 | ws.close(code, "bye"); |
| 1486 | } catch { |
| 1487 | /* already closed */ |
| 1488 | } |
| 1489 | } |
| 1490 | |
| 1491 | broadcast(topic: string, data: unknown): void { |
| 1492 | const msg = JSON.stringify({ topic, data }); |
| 1493 | for (const ws of this.ctx.getWebSockets(topic)) { |
| 1494 | try { |
| 1495 | ws.send(msg); |
| 1496 | } catch { |
| 1497 | /* client went away */ |
| 1498 | } |
| 1499 | } |
| 1500 | } |
| 1501 | } |
| 1502 | |
| 1503 | export function toInfo(sha: string, c: Commit): CommitInfo { |
| 1504 | return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig }; |
| 1505 | } |
| 1506 | |
| 1507 | export { fromUtf8 }; |