| 1 | /** |
| 2 | * GitHub REST v3 compatibility facade (`/api/v3/**`) — DESIGN.md "Tier 1". |
| 3 | * |
| 4 | * Lets GitHub-flavored tooling talk to the service unmodified. The target |
| 5 | * consumers are `gh api` (via `GH_HOST=<host>` + `GH_ENTERPRISE_TOKEN`, |
| 6 | * which sends REST to `https://<host>/api/v3/...`) and Octokit-style SDKs |
| 7 | * pointed at a GitHub Enterprise `baseUrl`. Only the REST plane is |
| 8 | * mimicked — the `gh pr`/`gh issue` porcelain commands speak GraphQL and |
| 9 | * are out of scope (use `gh api` paths instead). |
| 10 | * |
| 11 | * This module is a pure TRANSLATION layer: every route parses GitHub's |
| 12 | * request shapes, calls the same Repo-DO RPCs the native `/api/v1` plane |
| 13 | * uses (auth enforcement stays in the DO), and re-shapes responses into |
| 14 | * GitHub's JSON. GitHub-isms handled here: |
| 15 | * |
| 16 | * - `Authorization: token <t>` (gh's scheme) — parsed by `Auth.ts` |
| 17 | * alongside Bearer/Basic. |
| 18 | * - A merged PR is `state: "closed"` with `merged: true` (our tri-state |
| 19 | * `merged` collapses into GitHub's boolean). |
| 20 | * - Errors are `{ message }` with GitHub's status conventions (401 |
| 21 | * "Requires authentication", 404 "Not Found", 405 for unsupported merge |
| 22 | * methods, 409 for conflicts). |
| 23 | * - List pagination is RFC-5988 `Link: <...>; rel="next"` headers. The |
| 24 | * next URL carries an opaque `cursor` (not a page number) — `gh api |
| 25 | * --paginate` and Octokit follow the URL verbatim, so keyset cursors |
| 26 | * work transparently. |
| 27 | * |
| 28 | * Deliberately absent (documented, not accidental): issues, reviews, |
| 29 | * comments, checks, search, GraphQL, rename detection in file lists, and |
| 30 | * additions/deletions line counts (reported as 0 — the server does not |
| 31 | * compute content diffs; fetch blobs and diff client-side). |
| 32 | */ |
| 33 | import * as Effect from "effect/Effect"; |
| 34 | import { Base64 } from "effect/encoding"; |
| 35 | import * as Layer from "effect/Layer"; |
| 36 | import * as Result from "effect/Result"; |
| 37 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 38 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 39 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 40 | import type { RuntimeContext } from "../RuntimeContext.ts"; |
| 41 | import type { RegistryEntry } from "./RegistryObject.ts"; |
| 42 | import type { |
| 43 | CommitData, |
| 44 | CommitDiffData, |
| 45 | CompareData, |
| 46 | FileData, |
| 47 | MergePullResult, |
| 48 | PullData, |
| 49 | PullDetailData, |
| 50 | PullsPage, |
| 51 | RefsPage, |
| 52 | RepoMetaData, |
| 53 | CommitLogPage, |
| 54 | SignatureData, |
| 55 | } from "./RepoObject.ts"; |
| 56 | import type { DiffEntryData } from "./Protocol/TreeDiff.ts"; |
| 57 | |
| 58 | // ───────────────────────────────────────────────────────────────────────────── |
| 59 | // Dependencies injected by the Worker |
| 60 | // ───────────────────────────────────────────────────────────────────────────── |
| 61 | |
| 62 | /** Outcome of the Worker's shared owner/repo + credential prelude. */ |
| 63 | export type CompatPrelude = |
| 64 | | { |
| 65 | readonly kind: "halt"; |
| 66 | readonly response: HttpServerResponse.HttpServerResponse; |
| 67 | } |
| 68 | | { |
| 69 | readonly kind: "ok"; |
| 70 | readonly entry: RegistryEntry; |
| 71 | }; |
| 72 | |
| 73 | /** The slice of the Repo-DO stub the facade calls. */ |
| 74 | export interface CompatRepoStub { |
| 75 | readonly getRepoMeta: () => Effect.Effect< |
| 76 | RepoMetaData, |
| 77 | { readonly _tag: string }, |
| 78 | RuntimeContext |
| 79 | >; |
| 80 | readonly listRefs: ( |
| 81 | prefix?: string | undefined, |
| 82 | ) => Effect.Effect<RefsPage, { readonly _tag: string }, RuntimeContext>; |
| 83 | readonly readCommitLog: (input: { |
| 84 | readonly ref?: string | undefined; |
| 85 | readonly cursor?: string | undefined; |
| 86 | readonly limit?: number | undefined; |
| 87 | }) => Effect.Effect<CommitLogPage, { readonly _tag: string }, RuntimeContext>; |
| 88 | readonly readCommitDiff: (input: { |
| 89 | readonly oid: string; |
| 90 | }) => Effect.Effect< |
| 91 | CommitDiffData, |
| 92 | { readonly _tag: string }, |
| 93 | RuntimeContext |
| 94 | >; |
| 95 | readonly compareCommits: (input: { |
| 96 | readonly base: string; |
| 97 | readonly head: string; |
| 98 | }) => Effect.Effect<CompareData, { readonly _tag: string }, RuntimeContext>; |
| 99 | readonly readFileAtPath: (input: { |
| 100 | readonly ref?: string | undefined; |
| 101 | readonly path: string; |
| 102 | }) => Effect.Effect<FileData, { readonly _tag: string }, RuntimeContext>; |
| 103 | readonly createPull: (input: { |
| 104 | readonly title: string; |
| 105 | readonly body?: string | undefined; |
| 106 | readonly base: string; |
| 107 | readonly head: string; |
| 108 | }) => Effect.Effect<PullData, { readonly _tag: string }, RuntimeContext>; |
| 109 | readonly listPulls: (input: { |
| 110 | readonly state?: "open" | "closed" | "merged" | "all" | undefined; |
| 111 | readonly cursor?: string | undefined; |
| 112 | readonly limit?: number | undefined; |
| 113 | }) => Effect.Effect<PullsPage, { readonly _tag: string }, RuntimeContext>; |
| 114 | readonly getPull: ( |
| 115 | number: number, |
| 116 | ) => Effect.Effect<PullDetailData, { readonly _tag: string }, RuntimeContext>; |
| 117 | readonly updatePull: (input: { |
| 118 | readonly number: number; |
| 119 | readonly title?: string | undefined; |
| 120 | readonly body?: string | null | undefined; |
| 121 | readonly state?: "open" | "closed" | undefined; |
| 122 | }) => Effect.Effect<PullData, { readonly _tag: string }, RuntimeContext>; |
| 123 | readonly mergePull: (input: { |
| 124 | readonly number: number; |
| 125 | readonly message?: string | undefined; |
| 126 | readonly expectedHeadOid?: string | undefined; |
| 127 | }) => Effect.Effect< |
| 128 | MergePullResult, |
| 129 | { readonly _tag: string }, |
| 130 | RuntimeContext |
| 131 | >; |
| 132 | } |
| 133 | |
| 134 | export interface GitHubCompatOptions { |
| 135 | /** |
| 136 | * The Worker's `rawRestPrelude`: resolves `owner/repo`, parses |
| 137 | * credentials (anonymous allowed — public repos), and 404s/500s early. |
| 138 | */ |
| 139 | readonly prelude: ( |
| 140 | owner: string, |
| 141 | repo: string, |
| 142 | ) => Effect.Effect< |
| 143 | CompatPrelude, |
| 144 | never, |
| 145 | HttpServerRequest.HttpServerRequest | RuntimeContext |
| 146 | >; |
| 147 | /** Repo-DO stub by repoId (the Worker's `repos.getByName`). */ |
| 148 | readonly stub: (repoId: string) => CompatRepoStub; |
| 149 | } |
| 150 | |
| 151 | // ───────────────────────────────────────────────────────────────────────────── |
| 152 | // Small helpers |
| 153 | // ───────────────────────────────────────────────────────────────────────────── |
| 154 | |
| 155 | const OID_RE = /^[0-9a-f]{40}$/; |
| 156 | |
| 157 | const iso = (epochMs: number): string => new Date(epochMs).toISOString(); |
| 158 | |
| 159 | const isoSeconds = (sig: SignatureData): string => |
| 160 | new Date(sig.date * 1000).toISOString(); |
| 161 | |
| 162 | /** Stable 48-bit integer id from a string (GitHub ids are numbers). */ |
| 163 | const intId = (value: string): number => { |
| 164 | let hash = 0xcbf29ce484222325n; |
| 165 | for (let index = 0; index < value.length; index++) { |
| 166 | hash ^= BigInt(value.charCodeAt(index)); |
| 167 | hash = (hash * 0x100000001b3n) & 0xffffffffffffn; |
| 168 | } |
| 169 | return Number(hash); |
| 170 | }; |
| 171 | |
| 172 | const shortRef = (ref: string): string => ref.replace(/^refs\/heads\//, ""); |
| 173 | |
| 174 | /** `https://host` of the incoming request (same derivation as remotes). */ |
| 175 | const originOf = Effect.gen(function* () { |
| 176 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 177 | const host = request.headers.host; |
| 178 | const proto = request.headers["x-forwarded-proto"] ?? "https"; |
| 179 | return host === undefined ? "" : `${proto}://${host}`; |
| 180 | }); |
| 181 | |
| 182 | const ghJson = ( |
| 183 | value: unknown, |
| 184 | options?: { |
| 185 | readonly status?: number; |
| 186 | readonly headers?: Record<string, string>; |
| 187 | }, |
| 188 | ) => |
| 189 | HttpServerResponse.json(value, { |
| 190 | status: options?.status ?? 200, |
| 191 | headers: options?.headers, |
| 192 | }).pipe(Effect.orDie); |
| 193 | |
| 194 | const ghError = (status: number, message: string) => |
| 195 | ghJson({ message }, { status }); |
| 196 | |
| 197 | const ghNotFound = ghError(404, "Not Found"); |
| 198 | |
| 199 | /** |
| 200 | * Maps the DO's typed error union onto GitHub's status conventions. Every |
| 201 | * facade route ends with this — unknown tags become 500s via `Effect.die` |
| 202 | * (defects, not silent 200s). |
| 203 | */ |
| 204 | const ghCatch = <A, R>( |
| 205 | effect: Effect.Effect< |
| 206 | A, |
| 207 | { readonly _tag: string; readonly message?: string }, |
| 208 | R |
| 209 | >, |
| 210 | ): Effect.Effect<A | HttpServerResponse.HttpServerResponse, never, R> => |
| 211 | effect.pipe( |
| 212 | Effect.catchIf( |
| 213 | (error): error is { _tag: string } => typeof error?._tag === "string", |
| 214 | (error) => { |
| 215 | switch (error._tag) { |
| 216 | case "RepoNotFound": |
| 217 | case "RefNotFound": |
| 218 | case "ObjectNotFound": |
| 219 | case "PullNotFound": |
| 220 | case "BranchMissing": |
| 221 | return ghNotFound; |
| 222 | case "WrongObjectType": |
| 223 | case "ValidationError": |
| 224 | case "NoMergeBase": |
| 225 | return ghError(422, "Validation Failed"); |
| 226 | case "PullExists": |
| 227 | return ghError( |
| 228 | 422, |
| 229 | "Validation Failed: a pull request already exists for this head/base", |
| 230 | ); |
| 231 | case "PullStateConflict": |
| 232 | case "NothingToMerge": |
| 233 | return ghError(405, "Pull Request is not mergeable"); |
| 234 | case "MergeConflict": |
| 235 | return ghError(409, "Merge conflict"); |
| 236 | case "RefConflict": |
| 237 | return ghError( |
| 238 | 409, |
| 239 | "Head branch was modified. Review and try the merge again.", |
| 240 | ); |
| 241 | case "ReadOnlyRepo": |
| 242 | return ghError(403, "Repository is archived (read-only)"); |
| 243 | case "StoreError": |
| 244 | return ghError(500, "Internal error"); |
| 245 | default: |
| 246 | return Effect.die(error); |
| 247 | } |
| 248 | }, |
| 249 | ), |
| 250 | ); |
| 251 | |
| 252 | // ───────────────────────────────────────────────────────────────────────────── |
| 253 | // Shape mappers (our data → GitHub JSON) |
| 254 | // ───────────────────────────────────────────────────────────────────────────── |
| 255 | |
| 256 | const ghUser = (login: string) => ({ |
| 257 | login, |
| 258 | id: intId(login), |
| 259 | node_id: `U_${login}`, |
| 260 | type: "User", |
| 261 | site_admin: false, |
| 262 | }); |
| 263 | |
| 264 | const ghRepo = (meta: RepoMetaData, origin: string) => ({ |
| 265 | id: intId(meta.repoId), |
| 266 | node_id: meta.repoId, |
| 267 | name: meta.name, |
| 268 | full_name: `${meta.owner}/${meta.name}`, |
| 269 | owner: ghUser(meta.owner), |
| 270 | private: !meta.public, |
| 271 | visibility: meta.public ? "public" : "private", |
| 272 | html_url: `${origin}/${meta.owner}/${meta.name}`, |
| 273 | description: meta.description, |
| 274 | fork: meta.forkOf !== null, |
| 275 | url: `${origin}/api/v3/repos/${meta.owner}/${meta.name}`, |
| 276 | clone_url: `${origin}/${meta.owner}/${meta.name}.git`, |
| 277 | git_url: `${origin}/${meta.owner}/${meta.name}.git`, |
| 278 | ssh_url: `${origin}/${meta.owner}/${meta.name}.git`, |
| 279 | default_branch: meta.defaultBranch, |
| 280 | created_at: iso(meta.createdAt), |
| 281 | updated_at: iso(meta.createdAt), |
| 282 | pushed_at: iso(meta.createdAt), |
| 283 | archived: meta.readOnly, |
| 284 | disabled: false, |
| 285 | size: Math.ceil(meta.objects.bytes / 1024), |
| 286 | }); |
| 287 | |
| 288 | const ghCommit = (commit: CommitData, origin: string, repoUrl: string) => ({ |
| 289 | sha: commit.oid, |
| 290 | node_id: commit.oid, |
| 291 | url: `${repoUrl}/commits/${commit.oid}`, |
| 292 | html_url: `${origin.replace("/api/v3", "")}`, |
| 293 | commit: { |
| 294 | message: commit.message, |
| 295 | author: { |
| 296 | name: commit.author.name, |
| 297 | email: commit.author.email, |
| 298 | date: isoSeconds(commit.author), |
| 299 | }, |
| 300 | committer: { |
| 301 | name: commit.committer.name, |
| 302 | email: commit.committer.email, |
| 303 | date: isoSeconds(commit.committer), |
| 304 | }, |
| 305 | tree: { sha: commit.tree }, |
| 306 | }, |
| 307 | author: null, |
| 308 | committer: null, |
| 309 | parents: commit.parents.map((sha) => ({ |
| 310 | sha, |
| 311 | url: `${repoUrl}/commits/${sha}`, |
| 312 | })), |
| 313 | }); |
| 314 | |
| 315 | /** |
| 316 | * One changed file, GitHub `pulls/:n/files` shape. `additions`/`deletions` |
| 317 | * are 0 by design: the server never computes content diffs (DESIGN.md |
| 318 | * §"content diffs are client-side"). |
| 319 | */ |
| 320 | const ghFile = (entry: DiffEntryData) => ({ |
| 321 | sha: entry.newOid ?? entry.oldOid ?? "", |
| 322 | filename: entry.path, |
| 323 | status: entry.status, |
| 324 | additions: 0, |
| 325 | deletions: 0, |
| 326 | changes: 0, |
| 327 | previous_filename: undefined, |
| 328 | }); |
| 329 | |
| 330 | const ghPullState = (state: PullData["state"]): "open" | "closed" => |
| 331 | state === "open" ? "open" : "closed"; |
| 332 | |
| 333 | const ghPull = ( |
| 334 | pull: PullData & Partial<PullDetailData>, |
| 335 | meta: RepoMetaData, |
| 336 | origin: string, |
| 337 | ) => { |
| 338 | const repoUrl = `${origin}/api/v3/repos/${meta.owner}/${meta.name}`; |
| 339 | return { |
| 340 | id: intId(`${meta.repoId}#${pull.number}`), |
| 341 | node_id: `PR_${meta.repoId}_${pull.number}`, |
| 342 | number: pull.number, |
| 343 | state: ghPullState(pull.state), |
| 344 | merged: pull.state === "merged", |
| 345 | locked: false, |
| 346 | draft: false, |
| 347 | title: pull.title, |
| 348 | body: pull.body, |
| 349 | user: ghUser(meta.owner), |
| 350 | html_url: `${origin}/${meta.owner}/${meta.name}/pulls/${pull.number}`, |
| 351 | url: `${repoUrl}/pulls/${pull.number}`, |
| 352 | created_at: iso(pull.createdAt), |
| 353 | updated_at: iso(pull.updatedAt), |
| 354 | closed_at: pull.state === "open" ? null : iso(pull.updatedAt), |
| 355 | merged_at: pull.mergedAt === null ? null : iso(pull.mergedAt ?? 0), |
| 356 | merge_commit_sha: pull.mergeCommit, |
| 357 | base: { |
| 358 | ref: shortRef(pull.baseRef), |
| 359 | label: `${meta.owner}:${shortRef(pull.baseRef)}`, |
| 360 | sha: pull.baseOid ?? "", |
| 361 | repo: { full_name: `${meta.owner}/${meta.name}` }, |
| 362 | }, |
| 363 | head: { |
| 364 | ref: shortRef(pull.headRef), |
| 365 | label: `${meta.owner}:${shortRef(pull.headRef)}`, |
| 366 | sha: pull.headOid ?? "", |
| 367 | repo: { full_name: `${meta.owner}/${meta.name}` }, |
| 368 | }, |
| 369 | mergeable: pull.mergeable ?? null, |
| 370 | mergeable_state: |
| 371 | pull.mergeableReason === "conflict" |
| 372 | ? "dirty" |
| 373 | : pull.mergeable === true |
| 374 | ? "clean" |
| 375 | : "unknown", |
| 376 | }; |
| 377 | }; |
| 378 | |
| 379 | // ───────────────────────────────────────────────────────────────────────────── |
| 380 | // The routes |
| 381 | // ───────────────────────────────────────────────────────────────────────────── |
| 382 | |
| 383 | /** |
| 384 | * Builds the `/api/v3` route layer. Mounted by the GitWorker next to the |
| 385 | * git wire routes; every repo-scoped route flows through the same |
| 386 | * `prelude` (owner/name resolution) |
| 387 | * as the raw REST routes. |
| 388 | */ |
| 389 | export const gitHubCompatRoutes = (options: GitHubCompatOptions) => { |
| 390 | const { prelude, stub } = options; |
| 391 | |
| 392 | /** Repo-scoped route body: prelude, then handler with entry+auth. */ |
| 393 | const withRepo = <R>( |
| 394 | handler: (context: { |
| 395 | readonly entry: RegistryEntry; |
| 396 | readonly repo: CompatRepoStub; |
| 397 | readonly origin: string; |
| 398 | readonly repoUrl: string; |
| 399 | readonly request: HttpServerRequest.HttpServerRequest; |
| 400 | readonly url: URL; |
| 401 | readonly params: Readonly<Record<string, string | undefined>>; |
| 402 | }) => Effect.Effect< |
| 403 | HttpServerResponse.HttpServerResponse, |
| 404 | { readonly _tag: string }, |
| 405 | R |
| 406 | >, |
| 407 | ) => |
| 408 | Effect.gen(function* () { |
| 409 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 410 | const params = yield* HttpRouter.params; |
| 411 | const owner = (params.owner ?? "").toLowerCase(); |
| 412 | const name = (params.repo ?? "").toLowerCase(); |
| 413 | const resolved = yield* prelude(owner, name); |
| 414 | if (resolved.kind === "halt") { |
| 415 | // The prelude's plain-text halts become GitHub JSON errors. |
| 416 | const status = resolved.response.status; |
| 417 | return yield* status === 404 |
| 418 | ? ghNotFound |
| 419 | : ghError(status, "Internal error"); |
| 420 | } |
| 421 | const origin = yield* originOf; |
| 422 | const result = yield* ghCatch( |
| 423 | handler({ |
| 424 | entry: resolved.entry, |
| 425 | repo: stub(resolved.entry.repoId), |
| 426 | origin, |
| 427 | repoUrl: `${origin}/api/v3/repos/${owner}/${name}`, |
| 428 | request, |
| 429 | url: new URL(request.url, "http://internal"), |
| 430 | params, |
| 431 | }), |
| 432 | ); |
| 433 | return result as HttpServerResponse.HttpServerResponse; |
| 434 | }); |
| 435 | |
| 436 | /** `Link: <…>; rel="next"` for cursor pages, GitHub-style. */ |
| 437 | const linkHeaders = ( |
| 438 | base: string, |
| 439 | query: URLSearchParams, |
| 440 | nextCursor: string | null, |
| 441 | ): Record<string, string> => { |
| 442 | if (nextCursor === null) return {}; |
| 443 | const next = new URLSearchParams(query); |
| 444 | next.set("cursor", nextCursor); |
| 445 | return { link: `<${base}?${next}>; rel="next"` }; |
| 446 | }; |
| 447 | |
| 448 | const perPage = (url: URL): number => { |
| 449 | const raw = Number.parseInt(url.searchParams.get("per_page") ?? "30", 10); |
| 450 | return Number.isFinite(raw) ? Math.max(1, Math.min(raw, 100)) : 30; |
| 451 | }; |
| 452 | |
| 453 | /** Resolves `:sha` path segments that may be refs (GitHub allows both). */ |
| 454 | const resolveSha = (repo: CompatRepoStub, revision: string) => |
| 455 | OID_RE.test(revision) |
| 456 | ? Effect.succeed(revision) |
| 457 | : repo |
| 458 | .readCommitLog({ ref: revision, limit: 1 }) |
| 459 | .pipe(Effect.map((page) => page.items[0]?.oid ?? revision)); |
| 460 | |
| 461 | return { |
| 462 | /** `GET /api/v3/user` — the credential probe; override the `user` handler in your `github` group to answer from your users. */ |
| 463 | user: () => ghJson(ghUser("git")), |
| 464 | /** `GET /api/v3/repos/:owner/:repo` */ |
| 465 | repo: () => |
| 466 | withRepo(({ repo, origin }) => |
| 467 | repo |
| 468 | .getRepoMeta() |
| 469 | .pipe(Effect.flatMap((meta) => ghJson(ghRepo(meta, origin)))), |
| 470 | ), |
| 471 | /** `GET /api/v3/repos/:owner/:repo/branches` */ |
| 472 | branches: () => |
| 473 | withRepo(({ repo }) => |
| 474 | repo.listRefs("refs/heads/").pipe( |
| 475 | Effect.flatMap((page) => |
| 476 | ghJson( |
| 477 | page.refs.map((ref) => ({ |
| 478 | name: shortRef(ref.name), |
| 479 | commit: { sha: ref.oid }, |
| 480 | protected: false, |
| 481 | })), |
| 482 | ), |
| 483 | ), |
| 484 | ), |
| 485 | ), |
| 486 | /** `GET /api/v3/repos/:owner/:repo/commits` */ |
| 487 | commits: () => |
| 488 | withRepo(({ repo, origin, repoUrl, url }) => |
| 489 | repo |
| 490 | .readCommitLog({ |
| 491 | ref: url.searchParams.get("sha") ?? undefined, |
| 492 | cursor: url.searchParams.get("cursor") ?? undefined, |
| 493 | limit: perPage(url), |
| 494 | }) |
| 495 | .pipe( |
| 496 | Effect.flatMap((page) => |
| 497 | ghJson( |
| 498 | page.items.map((commit) => ghCommit(commit, origin, repoUrl)), |
| 499 | { |
| 500 | headers: linkHeaders( |
| 501 | `${repoUrl}/commits`, |
| 502 | url.searchParams, |
| 503 | page.hasMore ? page.nextCursor : null, |
| 504 | ), |
| 505 | }, |
| 506 | ), |
| 507 | ), |
| 508 | ), |
| 509 | ), |
| 510 | /** `GET /api/v3/repos/:owner/:repo/commits/:sha` */ |
| 511 | commit: () => |
| 512 | withRepo(({ repo, origin, repoUrl, params }) => |
| 513 | Effect.gen(function* () { |
| 514 | const oid = yield* resolveSha(repo, params.sha ?? ""); |
| 515 | const log = yield* repo.readCommitLog({ ref: oid, limit: 1 }); |
| 516 | const commit = log.items[0]; |
| 517 | if (commit === undefined) return yield* ghNotFound; |
| 518 | const diff = yield* repo.readCommitDiff({ oid }); |
| 519 | return yield* ghJson({ |
| 520 | ...ghCommit(commit, origin, repoUrl), |
| 521 | files: diff.files.map(ghFile), |
| 522 | }); |
| 523 | }), |
| 524 | ), |
| 525 | /** `GET /api/v3/repos/:owner/:repo/contents/*` */ |
| 526 | contents: () => |
| 527 | withRepo(({ repo, url, repoUrl }) => |
| 528 | Effect.gen(function* () { |
| 529 | const marker = "/contents/"; |
| 530 | const at = url.pathname.indexOf(marker); |
| 531 | const path = decodeURIComponent( |
| 532 | url.pathname.slice(at + marker.length), |
| 533 | ); |
| 534 | if (path.length === 0) return yield* ghNotFound; |
| 535 | const ref = url.searchParams.get("ref") ?? undefined; |
| 536 | const file = yield* repo.readFileAtPath({ ref, path }); |
| 537 | return yield* ghJson({ |
| 538 | type: "file", |
| 539 | encoding: "base64", |
| 540 | size: file.size, |
| 541 | name: path.split("/").pop(), |
| 542 | path, |
| 543 | sha: file.oid, |
| 544 | url: `${repoUrl}/contents/${path}`, |
| 545 | content: Base64.encode(file.content), |
| 546 | }); |
| 547 | }), |
| 548 | ), |
| 549 | /** `GET /api/v3/repos/:owner/:repo/pulls` */ |
| 550 | pulls: () => |
| 551 | withRepo(({ repo, entry, origin, repoUrl, url }) => |
| 552 | Effect.gen(function* () { |
| 553 | // GitHub's `closed` includes merged; our store distinguishes. |
| 554 | const requested = url.searchParams.get("state") ?? "open"; |
| 555 | const state = |
| 556 | requested === "open" |
| 557 | ? ("open" as const) |
| 558 | : requested === "all" |
| 559 | ? ("all" as const) |
| 560 | : ("all" as const); |
| 561 | const page = yield* repo.listPulls({ |
| 562 | state, |
| 563 | cursor: url.searchParams.get("cursor") ?? undefined, |
| 564 | limit: perPage(url), |
| 565 | }); |
| 566 | const meta = yield* repo.getRepoMeta(); |
| 567 | const items = page.items.filter((pull) => |
| 568 | requested === "all" |
| 569 | ? true |
| 570 | : requested === "open" |
| 571 | ? pull.state === "open" |
| 572 | : pull.state !== "open", |
| 573 | ); |
| 574 | return yield* ghJson( |
| 575 | items.map((pull) => ghPull(pull, meta, origin)), |
| 576 | { |
| 577 | headers: linkHeaders( |
| 578 | `${repoUrl}/pulls`, |
| 579 | url.searchParams, |
| 580 | page.hasMore ? page.nextCursor : null, |
| 581 | ), |
| 582 | }, |
| 583 | ); |
| 584 | }), |
| 585 | ), |
| 586 | /** `POST /api/v3/repos/:owner/:repo/pulls` */ |
| 587 | createPull: () => |
| 588 | withRepo(({ repo, origin, request }) => |
| 589 | Effect.gen(function* () { |
| 590 | const body = (yield* request.json.pipe( |
| 591 | Effect.mapError(() => ({ _tag: "ValidationError" as const })), |
| 592 | )) as { |
| 593 | title?: string; |
| 594 | head?: string; |
| 595 | base?: string; |
| 596 | body?: string; |
| 597 | }; |
| 598 | if (!body.title || !body.head || !body.base) { |
| 599 | return yield* ghError( |
| 600 | 422, |
| 601 | "Validation Failed: title, head and base are required", |
| 602 | ); |
| 603 | } |
| 604 | const pull = yield* repo.createPull({ |
| 605 | title: body.title, |
| 606 | body: body.body, |
| 607 | base: body.base, |
| 608 | head: body.head, |
| 609 | }); |
| 610 | const meta = yield* repo.getRepoMeta(); |
| 611 | return yield* ghJson(ghPull(pull, meta, origin), { status: 201 }); |
| 612 | }), |
| 613 | ), |
| 614 | /** `GET /api/v3/repos/:owner/:repo/pulls/:number` */ |
| 615 | pull: () => |
| 616 | withRepo(({ repo, origin, params }) => |
| 617 | Effect.gen(function* () { |
| 618 | const number = Number.parseInt(params.number ?? "", 10); |
| 619 | if (!Number.isFinite(number)) return yield* ghNotFound; |
| 620 | const pull = yield* repo.getPull(number); |
| 621 | const meta = yield* repo.getRepoMeta(); |
| 622 | return yield* ghJson(ghPull(pull, meta, origin)); |
| 623 | }), |
| 624 | ), |
| 625 | /** `PATCH /api/v3/repos/:owner/:repo/pulls/:number` */ |
| 626 | updatePull: () => |
| 627 | withRepo(({ repo, origin, params, request }) => |
| 628 | Effect.gen(function* () { |
| 629 | const number = Number.parseInt(params.number ?? "", 10); |
| 630 | if (!Number.isFinite(number)) return yield* ghNotFound; |
| 631 | const body = (yield* request.json.pipe( |
| 632 | Effect.mapError(() => ({ _tag: "ValidationError" as const })), |
| 633 | )) as { title?: string; body?: string | null; state?: string }; |
| 634 | if ( |
| 635 | body.state !== undefined && |
| 636 | body.state !== "open" && |
| 637 | body.state !== "closed" |
| 638 | ) { |
| 639 | return yield* ghError(422, "Validation Failed: invalid state"); |
| 640 | } |
| 641 | const pull = yield* repo.updatePull({ |
| 642 | number, |
| 643 | title: body.title, |
| 644 | body: body.body, |
| 645 | state: body.state as "open" | "closed" | undefined, |
| 646 | }); |
| 647 | const meta = yield* repo.getRepoMeta(); |
| 648 | return yield* ghJson(ghPull(pull, meta, origin)); |
| 649 | }), |
| 650 | ), |
| 651 | /** `PUT /api/v3/repos/:owner/:repo/pulls/:number/merge` */ |
| 652 | mergePull: () => |
| 653 | withRepo(({ repo, params, request }) => |
| 654 | Effect.gen(function* () { |
| 655 | const number = Number.parseInt(params.number ?? "", 10); |
| 656 | if (!Number.isFinite(number)) return yield* ghNotFound; |
| 657 | const raw = yield* request.json.pipe( |
| 658 | Effect.catch(() => Effect.succeed({})), |
| 659 | ); |
| 660 | const body = raw as { |
| 661 | commit_message?: string; |
| 662 | sha?: string; |
| 663 | merge_method?: string; |
| 664 | }; |
| 665 | if ( |
| 666 | body.merge_method !== undefined && |
| 667 | body.merge_method !== "merge" |
| 668 | ) { |
| 669 | // squash/rebase rewrite history server-side — not supported. |
| 670 | return yield* ghError( |
| 671 | 405, |
| 672 | `Merge method '${body.merge_method}' is not supported`, |
| 673 | ); |
| 674 | } |
| 675 | const result = yield* repo.mergePull({ |
| 676 | number, |
| 677 | message: body.commit_message, |
| 678 | expectedHeadOid: body.sha, |
| 679 | }); |
| 680 | return yield* ghJson({ |
| 681 | sha: result.oid, |
| 682 | merged: true, |
| 683 | message: "Pull Request successfully merged", |
| 684 | }); |
| 685 | }), |
| 686 | ), |
| 687 | /** `GET /api/v3/repos/:owner/:repo/pulls/:number/files` */ |
| 688 | pullFiles: () => |
| 689 | withRepo(({ repo, params }) => |
| 690 | Effect.gen(function* () { |
| 691 | const number = Number.parseInt(params.number ?? "", 10); |
| 692 | if (!Number.isFinite(number)) return yield* ghNotFound; |
| 693 | const pull = yield* repo.getPull(number); |
| 694 | // Open PR with live branches: three-dot compare. Merged PR: the |
| 695 | // merge commit's first-parent diff is the canonical record. |
| 696 | if (pull.baseOid !== null && pull.headOid !== null) { |
| 697 | const compare = yield* repo.compareCommits({ |
| 698 | base: pull.baseRef, |
| 699 | head: pull.headRef, |
| 700 | }); |
| 701 | return yield* ghJson(compare.files.map(ghFile)); |
| 702 | } |
| 703 | if (pull.mergeCommit !== null) { |
| 704 | const diff = yield* repo.readCommitDiff({ |
| 705 | oid: pull.mergeCommit, |
| 706 | }); |
| 707 | return yield* ghJson(diff.files.map(ghFile)); |
| 708 | } |
| 709 | return yield* ghJson([]); |
| 710 | }), |
| 711 | ), |
| 712 | }; |
| 713 | }; |