Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Initial g1t: services, event bus, intents and attempts | 1 | import { WorkerEntrypoint } from "cloudflare:workers"; |
| 2 | ||
| 3 | import { | |
| 4 | type BlobView, | |
| 5 | type Commit, | |
| 6 | type CreateRepoInput, | |
| 7 | type EventsApi, | |
| 8 | type GitAccess, | |
| 9 | type GitService, | |
| 10 | type IdentityApi, | |
| 11 | type NewEvent, | |
| 12 | type Repo, | |
| 13 | type RepoPath, | |
| 14 | type ReposApi, | |
| 15 | type Result, | |
| 16 | type TreeView, | |
| 17 | type User, | |
| 18 | type Viewer, | |
| 19 | fail, | |
| 20 | isValidNamespace, | |
| 21 | isValidRepoName, | |
| 22 | newId, | |
| 23 | ok, | |
| 24 | } from "@g1t/contracts"; | |
| 25 | ||
| 26 | import { ArtifactsGitStore } from "./artifacts-git-store"; | |
| 27 | import { handleGitHttp } from "./git-http"; | |
| 28 | import type { GitStore } from "./git-store"; | |
| 29 | import { | |
| 30 | RepoRegistry, | |
| 31 | canRead, | |
| 32 | canWrite, | |
| 33 | storeKey, | |
| 34 | } from "./registry"; | |
| 35 | ||
| 36 | export interface ReposEnv { | |
| 37 | DB: D1Database; | |
| 38 | ARTIFACTS: Artifacts; | |
| 39 | IDENTITY: IdentityApi; | |
| 40 | EVENTS: EventsApi; | |
| 41 | } | |
| 42 | ||
| 43 | /** Namespace that holds every attempt's fork: `attempts/<attempt id>`. */ | |
| 44 | const ATTEMPTS_NAMESPACE = "attempts"; | |
| 45 | const MAX_TEXT_BYTES = 512 * 1024; | |
| 46 | const README = /^readme(\.(md|markdown|txt))?$/i; | |
| 47 | const SOURCE = "repos"; | |
| 48 | ||
| 49 | const NOT_FOUND = fail("not_found", "Repository not found."); | |
| 50 | ||
| 51 | /** Decoded text, or null when the blob is too large or looks binary. */ | |
| 52 | async function blobText(blob: Blob): Promise<string | null> { | |
| 53 | if (blob.size > MAX_TEXT_BYTES) return null; | |
| 54 | const bytes = new Uint8Array(await blob.arrayBuffer()); | |
| 55 | if (bytes.includes(0)) return null; | |
| 56 | return new TextDecoder().decode(bytes); | |
| 57 | } | |
| 58 | ||
| 59 | export default class ReposService | |
| 60 | extends WorkerEntrypoint<ReposEnv> | |
| 61 | implements ReposApi | |
| 62 | { | |
| 63 | private readonly registry = new RepoRegistry(this.env.DB); | |
| 64 | private readonly store: GitStore = new ArtifactsGitStore(this.env.ARTIFACTS); | |
| 65 | ||
| 66 | /** Resolves a repo the viewer may read; private repos look missing. */ | |
| 67 | private async readable(path: RepoPath, viewer: Viewer): Promise<Repo | null> { | |
| 68 | const repo = await this.registry.byPath(path); | |
| 69 | return repo && canRead(repo, viewer) ? repo : null; | |
| 70 | } | |
| 71 | ||
| 72 | async get(path: RepoPath, viewer: Viewer): Promise<Result<Repo>> { | |
| 73 | const repo = await this.readable(path, viewer); | |
| 74 | return repo ? ok(repo) : NOT_FOUND; | |
| 75 | } | |
| 76 | ||
| 77 | async getById(id: string, viewer: Viewer): Promise<Result<Repo>> { | |
| 78 | const repo = await this.registry.byId(id); | |
| 79 | return repo && canRead(repo, viewer) ? ok(repo) : NOT_FOUND; | |
| 80 | } | |
| 81 | ||
| 82 | async list( | |
| 83 | viewer: Viewer, | |
| 84 | options: { query?: string; namespace?: string } = {}, | |
| 85 | ): Promise<Repo[]> { | |
| 86 | return this.registry.list(viewer, options); | |
| 87 | } | |
| 88 | ||
| 89 | async create(owner: User, input: CreateRepoInput): Promise<Result<Repo>> { | |
| 90 | const name = input.name.trim().toLowerCase(); | |
| 91 | if (!isValidRepoName(name)) { | |
| 92 | return fail("invalid", "Use letters, digits, dots, hyphens and underscores only."); | |
| 93 | } | |
| 94 | if (!isValidNamespace(owner.username)) { | |
| 95 | return fail("invalid", "This account cannot own repositories."); | |
| 96 | } | |
| 97 | const path = { namespace: owner.username, name }; | |
| 98 | if (await this.registry.byPath(path)) { | |
| 99 | return fail("conflict", "You already have a repository with that name."); | |
| 100 | } | |
| 101 | const repo: Repo = { | |
| 102 | id: newId("rep"), | |
| 103 | ...path, | |
| 104 | description: input.description?.trim() || null, | |
| 105 | isPrivate: input.isPrivate ?? false, | |
| 106 | ownerId: owner.id, | |
| 107 | defaultBranch: "main", | |
| 108 | forkOf: null, | |
| 109 | createdAt: Date.now(), | |
| 110 | }; | |
| 111 | await this.store.create(storeKey(repo), { | |
| 112 | description: repo.description ?? undefined, | |
| 113 | defaultBranch: repo.defaultBranch, | |
| 114 | }); | |
| 115 | await this.registry.insert(repo); | |
| 116 | await this.publish({ | |
| 117 | type: "repo.created", | |
| 118 | source: SOURCE, | |
| 119 | repoId: repo.id, | |
| 120 | actor: owner.id, | |
| 121 | data: { | |
| 122 | repoId: repo.id, | |
| 123 | namespace: repo.namespace, | |
| 124 | name: repo.name, | |
| 125 | isPrivate: repo.isPrivate, | |
| 126 | }, | |
| 127 | }); | |
| 128 | return ok(repo); | |
| 129 | } | |
| 130 | ||
| 131 | async tree( | |
| 132 | path: RepoPath, | |
| 133 | viewer: Viewer, | |
| 134 | ref: string | null, | |
| 135 | treePath: string, | |
| 136 | ): Promise<Result<TreeView>> { | |
| 137 | const repo = await this.readable(path, viewer); | |
| 138 | if (!repo) return NOT_FOUND; | |
| 139 | const key = storeKey(repo); | |
| 140 | const resolvedRef = ref ?? repo.defaultBranch; | |
| 141 | const base = { repo, ref: resolvedRef, path: treePath }; | |
| 142 | ||
| 143 | const [head] = await this.store.log(key, resolvedRef, 1); | |
| 144 | if (!head) { | |
| 145 | // An unknown ref is an error; a repo with no commits is just empty. | |
| 146 | if (ref) return fail("not_found", "No such branch, tag or commit."); | |
| 147 | return ok({ ...base, head: null, entries: [], readme: null }); | |
| 148 | } | |
| 149 | ||
| 150 | let entries = await this.store.readTree(key, head.treeHash); | |
| 151 | for (const segment of treePath.split("/").filter(Boolean)) { | |
| 152 | const next = entries?.find( | |
| 153 | (entry) => entry.name === segment && entry.kind === "tree", | |
| 154 | ); | |
| 155 | if (!next) return fail("not_found", "No such directory."); | |
| 156 | entries = await this.store.readTree(key, next.hash); | |
| 157 | } | |
| 158 | if (!entries) return fail("not_found", "No such directory."); | |
| 159 | entries.sort( | |
| 160 | (a, b) => | |
| 161 | Number(b.kind === "tree") - Number(a.kind === "tree") || | |
| 162 | a.name.localeCompare(b.name), | |
| 163 | ); | |
| 164 | ||
| 165 | const readmeEntry = entries.find( | |
| 166 | (entry) => entry.kind === "blob" && README.test(entry.name), | |
| 167 | ); | |
| 168 | const readmeBlob = readmeEntry | |
| 169 | ? await this.store.readBlob(key, readmeEntry.hash) | |
| 170 | : null; | |
| 171 | const readme = | |
| 172 | readmeEntry && readmeBlob | |
| 173 | ? { name: readmeEntry.name, text: await blobText(readmeBlob) } | |
| 174 | : null; | |
| 175 | return ok({ ...base, head, entries, readme }); | |
| 176 | } | |
| 177 | ||
| 178 | async blob( | |
| 179 | path: RepoPath, | |
| 180 | viewer: Viewer, | |
| 181 | ref: string, | |
| 182 | filePath: string, | |
| 183 | ): Promise<Result<BlobView>> { | |
| 184 | const repo = await this.readable(path, viewer); | |
| 185 | if (!repo) return NOT_FOUND; | |
| 186 | const blob = filePath | |
| 187 | ? await this.store.readFile(storeKey(repo), ref, filePath) | |
| 188 | : null; | |
| 189 | if (!blob) return fail("not_found", "No such file."); | |
| 190 | return ok({ | |
| 191 | repo, | |
| 192 | ref, | |
| 193 | path: filePath, | |
| 194 | size: blob.size, | |
| 195 | text: await blobText(blob), | |
| 196 | }); | |
| 197 | } | |
| 198 | ||
| 199 | async log( | |
| 200 | path: RepoPath, | |
| 201 | viewer: Viewer, | |
| 202 | ref: string | null, | |
| 203 | limit: number, | |
| 204 | ): Promise<Result<Commit[]>> { | |
| 205 | const repo = await this.readable(path, viewer); | |
| 206 | if (!repo) return NOT_FOUND; | |
| 207 | return ok( | |
| 208 | await this.store.log(storeKey(repo), ref ?? repo.defaultBranch, limit), | |
| 209 | ); | |
| 210 | } | |
| 211 | ||
| 212 | async forkForAttempt( | |
| 213 | sourceId: string, | |
| 214 | attemptId: string, | |
| 215 | actor: User, | |
| 216 | ): Promise<Result<Repo>> { | |
| 217 | const source = await this.registry.byId(sourceId); | |
| 218 | if (!source || !canRead(source, actor)) return NOT_FOUND; | |
| 219 | const fork: Repo = { | |
| 220 | id: newId("rep"), | |
| 221 | namespace: ATTEMPTS_NAMESPACE, | |
| 222 | name: attemptId, | |
| 223 | description: null, | |
| 224 | // A fork is exactly as visible as the repo it came from. | |
| 225 | isPrivate: source.isPrivate, | |
| 226 | ownerId: actor.id, | |
| 227 | defaultBranch: source.defaultBranch, | |
| 228 | forkOf: source.id, | |
| 229 | createdAt: Date.now(), | |
| 230 | }; | |
| 231 | await this.store.fork(storeKey(source), storeKey(fork)); | |
| 232 | await this.registry.insert(fork); | |
| 233 | await this.publish({ | |
| 234 | type: "repo.forked", | |
| 235 | source: SOURCE, | |
| 236 | repoId: source.id, | |
| 237 | actor: actor.id, | |
| 238 | data: { repoId: fork.id, sourceRepoId: source.id, attemptId }, | |
| 239 | }); | |
| 240 | return ok(fork); | |
| 241 | } | |
| 242 | ||
| 243 | async gitAccess( | |
| 244 | path: RepoPath, | |
| 245 | viewer: Viewer, | |
| 246 | service: GitService, | |
| 247 | ): Promise<Result<GitAccess>> { | |
| 248 | const write = service === "git-receive-pack"; | |
| 249 | let repo = await this.registry.byPath(path); | |
| 250 | if (!repo) { | |
| 251 | // Push to create, in the pusher's own namespace only. | |
| 252 | if (!write || !viewer || viewer.username !== path.namespace.toLowerCase()) { | |
| 253 | return this.denied(viewer); | |
| 254 | } | |
| 255 | const created = await this.create(viewer, { name: path.name }); | |
| 256 | if (!created.ok) return created; | |
| 257 | repo = created.value; | |
| 258 | } else if (write ? !canWrite(repo, viewer) : !canRead(repo, viewer)) { | |
| 259 | return this.denied(viewer); | |
| 260 | } | |
| 261 | return ok(await this.store.access(storeKey(repo), write ? "write" : "read")); | |
| 262 | } | |
| 263 | ||
| 264 | /** | |
| 265 | * Anonymous callers are asked to authenticate whether or not the repo | |
| 266 | * exists, so private repos cannot be told apart from missing ones. | |
| 267 | */ | |
| 268 | private denied(viewer: Viewer): Result<never> { | |
| 269 | return viewer | |
| 270 | ? NOT_FOUND | |
| 271 | : fail("unauthenticated", "Authentication required."); | |
| 272 | } | |
| 273 | ||
| 274 | private async publish(event: NewEvent): Promise<void> { | |
| 275 | await this.env.EVENTS.publish([event]); | |
| 276 | } | |
| 277 | ||
| 278 | /** Git over HTTPS. */ | |
| 279 | async fetch(request: Request): Promise<Response> { | |
| 280 | const response = await handleGitHttp(request, this.env.IDENTITY, this, (path) => | |
| 281 | this.ctx.waitUntil(this.pushed(path)), | |
| 282 | ); | |
| 283 | return response ?? new Response("Not found\n", { status: 404 }); | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * Publishes `git.push` after a push has gone through the git front end. | |
| 288 | * Artifacts' own push subscriptions are per repository, which does not fit | |
| 289 | * a repo per attempt, so the front end reports pushes itself. | |
| 290 | */ | |
| 291 | private async pushed(path: RepoPath): Promise<void> { | |
| 292 | const repo = await this.registry.byPath(path); | |
| 293 | if (!repo) return; | |
| 294 | const [head] = await this.store.log(storeKey(repo), repo.defaultBranch, 1); | |
| 295 | if (!head) return; | |
| 296 | await this.publish({ | |
| 297 | type: "git.push", | |
| 298 | source: SOURCE, | |
| 299 | repoId: repo.id, | |
| 300 | actor: null, | |
| 301 | data: { | |
| 302 | repoId: repo.id, | |
| 303 | ref: `refs/heads/${repo.defaultBranch}`, | |
| 304 | after: head.hash, | |
| 305 | }, | |
| 306 | }); | |
| 307 | } | |
| 308 | } |