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 */
33import * as Effect from "effect/Effect";
34import { Base64 } from "effect/encoding";
35import * as Layer from "effect/Layer";
36import * as Result from "effect/Result";
37import * as HttpRouter from "effect/http/HttpRouter";
38import * as HttpServerRequest from "effect/http/HttpServerRequest";
39import * as HttpServerResponse from "effect/http/HttpServerResponse";
40import type { RuntimeContext } from "../RuntimeContext.ts";
41import type { RegistryEntry } from "./RegistryObject.ts";
42import 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";
56import 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. */
63export 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. */
74export 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
134export 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
155const OID_RE = /^[0-9a-f]{40}$/;
156
157const iso = (epochMs: number): string => new Date(epochMs).toISOString();
158
159const 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). */
163const 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
172const shortRef = (ref: string): string => ref.replace(/^refs\/heads\//, "");
173
174/** `https://host` of the incoming request (same derivation as remotes). */
175const 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
182const 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
194const ghError = (status: number, message: string) =>
195 ghJson({ message }, { status });
196
197const 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 */
204const 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
256const ghUser = (login: string) => ({
257 login,
258 id: intId(login),
259 node_id: `U_${login}`,
260 type: "User",
261 site_admin: false,
262});
263
264const 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
288const 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 */
320const 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
330const ghPullState = (state: PullData["state"]): "open" | "closed" =>
331 state === "open" ? "open" : "closed";
332
333const 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 */
389export 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};