| 1 | /** |
| 2 | * Plain-fetch client of the git-service REST API (`/api/v1`). |
| 3 | * |
| 4 | * The types mirror `alchemy/Git`'s API schemas (`src/Git/Api/Schema.ts`) |
| 5 | * by hand — the SPA deliberately ships no Effect runtime; it is an example |
| 6 | * of consuming the service from any plain JS frontend. |
| 7 | */ |
| 8 | |
| 9 | // ── configuration ─────────────────────────────────────────────────────────── |
| 10 | |
| 11 | const builtinUrl: string | undefined = import.meta.env.VITE_GIT_URL; |
| 12 | |
| 13 | /** |
| 14 | * Where the service lives. Same origin as the SPA in the single-origin |
| 15 | * deployment, so the Better Auth session cookie rides along with every |
| 16 | * request (`credentials: "include"`). |
| 17 | */ |
| 18 | export interface Connection { |
| 19 | readonly url: string; |
| 20 | } |
| 21 | |
| 22 | export const getConnection = (): Connection => ({ |
| 23 | url: (builtinUrl ?? location.origin).replace(/\/+$/, ""), |
| 24 | }); |
| 25 | |
| 26 | // ── auth (Better Auth, mounted at /api/auth) ──────────────────────────────── |
| 27 | |
| 28 | export interface User { |
| 29 | readonly id: string; |
| 30 | readonly name: string; |
| 31 | readonly email: string; |
| 32 | } |
| 33 | |
| 34 | const authRequest = async <T>( |
| 35 | c: Connection, |
| 36 | method: string, |
| 37 | path: string, |
| 38 | body?: unknown, |
| 39 | ): Promise<T> => { |
| 40 | const res = await fetch(`${c.url}/api/auth${path}`, { |
| 41 | method, |
| 42 | credentials: "include", |
| 43 | headers: body !== undefined ? { "Content-Type": "application/json" } : {}, |
| 44 | body: body !== undefined ? JSON.stringify(body) : undefined, |
| 45 | }); |
| 46 | if (!res.ok) { |
| 47 | const text = await res.text(); |
| 48 | let message = text; |
| 49 | try { |
| 50 | message = (JSON.parse(text) as { message?: string }).message ?? text; |
| 51 | } catch { |
| 52 | /* not JSON */ |
| 53 | } |
| 54 | throw new ApiError(res.status, "AuthError", message || res.statusText); |
| 55 | } |
| 56 | const text = await res.text(); |
| 57 | return (text.length === 0 ? null : JSON.parse(text)) as T; |
| 58 | }; |
| 59 | |
| 60 | export const getSession = (c: Connection): Promise<User | null> => |
| 61 | authRequest<{ user: User } | null>(c, "GET", "/get-session").then( |
| 62 | (session) => session?.user ?? null, |
| 63 | ); |
| 64 | |
| 65 | export const signUp = ( |
| 66 | c: Connection, |
| 67 | input: { name: string; email: string; password: string }, |
| 68 | ): Promise<User> => |
| 69 | authRequest<{ user: User }>(c, "POST", "/sign-up/email", input).then( |
| 70 | (r) => r.user, |
| 71 | ); |
| 72 | |
| 73 | export const signIn = ( |
| 74 | c: Connection, |
| 75 | input: { email: string; password: string }, |
| 76 | ): Promise<User> => |
| 77 | authRequest<{ user: User }>(c, "POST", "/sign-in/email", input).then( |
| 78 | (r) => r.user, |
| 79 | ); |
| 80 | |
| 81 | export const signOut = (c: Connection): Promise<void> => |
| 82 | authRequest<unknown>(c, "POST", "/sign-out", {}).then(() => undefined); |
| 83 | |
| 84 | /** An API key: the password a `git` remote carries. Values are shown once. */ |
| 85 | export interface ApiKey { |
| 86 | readonly id: string; |
| 87 | readonly name: string | null; |
| 88 | /** The first characters of the key, for recognition. */ |
| 89 | readonly start: string | null; |
| 90 | readonly createdAt: string; |
| 91 | readonly expiresAt: string | null; |
| 92 | } |
| 93 | |
| 94 | export const listApiKeys = (c: Connection): Promise<ApiKey[]> => |
| 95 | authRequest<ApiKey[]>(c, "GET", "/api-key/list"); |
| 96 | |
| 97 | export const createApiKey = ( |
| 98 | c: Connection, |
| 99 | name: string, |
| 100 | ): Promise<ApiKey & { key: string }> => |
| 101 | authRequest(c, "POST", "/api-key/create", { name }); |
| 102 | |
| 103 | export const deleteApiKey = (c: Connection, keyId: string): Promise<void> => |
| 104 | authRequest<unknown>(c, "POST", "/api-key/delete", { keyId }).then( |
| 105 | () => undefined, |
| 106 | ); |
| 107 | |
| 108 | export type RepoStatus = "ready" | "importing" | "forking" | "deleting"; |
| 109 | |
| 110 | export interface ObjectStats { |
| 111 | loose: number; |
| 112 | packed: number; |
| 113 | r2: number; |
| 114 | bytes: number; |
| 115 | } |
| 116 | |
| 117 | export interface PushStats { |
| 118 | objects: number; |
| 119 | bytes: number; |
| 120 | ingestMs: number; |
| 121 | stageMs: number; |
| 122 | connectivityMs: number; |
| 123 | finalizeMs: number; |
| 124 | totalMs: number; |
| 125 | } |
| 126 | |
| 127 | export interface Repo { |
| 128 | owner: string; |
| 129 | name: string; |
| 130 | repoId: string; |
| 131 | defaultBranch: string; |
| 132 | description: string | null; |
| 133 | readOnly: boolean; |
| 134 | /** Readable (REST + clone) without signing in. */ |
| 135 | public: boolean; |
| 136 | forkOf: string | null; |
| 137 | status: RepoStatus; |
| 138 | createdAt: number; |
| 139 | objects: ObjectStats; |
| 140 | lastPush: PushStats | null; |
| 141 | } |
| 142 | |
| 143 | export interface Ref { |
| 144 | name: string; |
| 145 | oid: string; |
| 146 | peeled?: string; |
| 147 | } |
| 148 | |
| 149 | export interface Signature { |
| 150 | name: string; |
| 151 | email: string; |
| 152 | /** Unix timestamp (seconds). */ |
| 153 | date: number; |
| 154 | /** Timezone offset as written, e.g. `+0200`. */ |
| 155 | tz: string; |
| 156 | } |
| 157 | |
| 158 | export interface CommitInfo { |
| 159 | oid: string; |
| 160 | tree: string; |
| 161 | parents: string[]; |
| 162 | author: Signature; |
| 163 | committer: Signature; |
| 164 | message: string; |
| 165 | } |
| 166 | |
| 167 | export interface TreeEntry { |
| 168 | mode: string; |
| 169 | name: string; |
| 170 | oid: string; |
| 171 | type: "blob" | "tree" | "commit"; |
| 172 | } |
| 173 | |
| 174 | export type FileStatus = "added" | "removed" | "modified"; |
| 175 | |
| 176 | /** |
| 177 | * One changed file in a commit diff or comparison. Content is NOT |
| 178 | * included — clients fetch old/new blobs by oid (`getBlob`) and diff |
| 179 | * locally. `oldSize`/`newSize` gate binary/oversize files without a |
| 180 | * round trip. Gitlinks (mode `160000`) carry commit oids — render |
| 181 | * "Subproject commit …", never fetch them as blobs. No rename detection |
| 182 | * in v1 (a rename is `removed` + `added`); a mode-only change is |
| 183 | * `modified` with `oldOid === newOid`. |
| 184 | */ |
| 185 | export interface DiffEntry { |
| 186 | path: string; |
| 187 | status: FileStatus; |
| 188 | oldOid?: string | null; |
| 189 | newOid?: string | null; |
| 190 | oldMode?: string | null; |
| 191 | newMode?: string | null; |
| 192 | oldSize?: number | null; |
| 193 | newSize?: number | null; |
| 194 | } |
| 195 | |
| 196 | /** Changed files of one commit vs its FIRST parent (null for a root commit). */ |
| 197 | export interface CommitDiff { |
| 198 | oid: string; |
| 199 | parent: string | null; |
| 200 | files: DiffEntry[]; |
| 201 | /** `true` when the list was cut at the server cap (1000 files). */ |
| 202 | truncated: boolean; |
| 203 | } |
| 204 | |
| 205 | /** GitHub-style three-dot comparison of two revisions. */ |
| 206 | export interface Comparison { |
| 207 | base: string; |
| 208 | head: string; |
| 209 | mergeBase: string; |
| 210 | aheadBy: number; |
| 211 | behindBy: number; |
| 212 | /** Head-side commits, committer-time descending, capped at 250. */ |
| 213 | commits: CommitInfo[]; |
| 214 | commitsTruncated: boolean; |
| 215 | /** File diff of mergeBase..head (three-dot). */ |
| 216 | files: DiffEntry[]; |
| 217 | filesTruncated: boolean; |
| 218 | } |
| 219 | |
| 220 | export interface RepoCreated { |
| 221 | repo: Repo; |
| 222 | remote: string; |
| 223 | } |
| 224 | |
| 225 | export interface Page<T> { |
| 226 | items: T[]; |
| 227 | nextCursor: string | null; |
| 228 | hasMore: boolean; |
| 229 | } |
| 230 | |
| 231 | // ── errors ────────────────────────────────────────────────────────────────── |
| 232 | |
| 233 | export class ApiError extends Error { |
| 234 | constructor( |
| 235 | readonly status: number, |
| 236 | readonly tag: string, |
| 237 | override readonly message: string, |
| 238 | ) { |
| 239 | super(message); |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | const parseError = async (res: Response): Promise<ApiError> => { |
| 244 | let tag = `HTTP ${res.status}`; |
| 245 | let message = res.statusText; |
| 246 | try { |
| 247 | const body: unknown = await res.clone().json(); |
| 248 | if (typeof body === "object" && body !== null) { |
| 249 | const record = body as Record<string, unknown>; |
| 250 | if (typeof record._tag === "string") tag = record._tag; |
| 251 | if (typeof record.message === "string") message = record.message; |
| 252 | else if (typeof record.reason === "string") message = record.reason; |
| 253 | } |
| 254 | } catch { |
| 255 | try { |
| 256 | message = (await res.text()) || message; |
| 257 | } catch { |
| 258 | /* keep statusText */ |
| 259 | } |
| 260 | } |
| 261 | return new ApiError(res.status, tag, message || tag); |
| 262 | }; |
| 263 | |
| 264 | // ── transport ─────────────────────────────────────────────────────────────── |
| 265 | |
| 266 | const request = async <T>( |
| 267 | connection: Connection, |
| 268 | method: string, |
| 269 | path: string, |
| 270 | body?: unknown, |
| 271 | ): Promise<T> => { |
| 272 | const res = await fetch(`${connection.url}/api/v1${path}`, { |
| 273 | method, |
| 274 | // The Better Auth session cookie is the credential; anonymous |
| 275 | // requests carry none and the policy confines them to public reads. |
| 276 | credentials: "include", |
| 277 | headers: body !== undefined ? { "Content-Type": "application/json" } : {}, |
| 278 | body: body !== undefined ? JSON.stringify(body) : undefined, |
| 279 | }); |
| 280 | if (!res.ok) throw await parseError(res); |
| 281 | if (res.status === 204) return undefined as T; |
| 282 | const text = await res.text(); |
| 283 | return (text.length === 0 ? undefined : JSON.parse(text)) as T; |
| 284 | }; |
| 285 | |
| 286 | const seg = encodeURIComponent; |
| 287 | |
| 288 | // ── repos ─────────────────────────────────────────────────────────────────── |
| 289 | |
| 290 | export const listRepos = ( |
| 291 | c: Connection, |
| 292 | query?: { owner?: string; cursor?: string; limit?: number }, |
| 293 | ): Promise<Page<Repo>> => { |
| 294 | const params = new URLSearchParams(); |
| 295 | if (query?.owner) params.set("owner", query.owner); |
| 296 | if (query?.cursor) params.set("cursor", query.cursor); |
| 297 | if (query?.limit) params.set("limit", String(query.limit)); |
| 298 | const qs = params.size > 0 ? `?${params}` : ""; |
| 299 | return request(c, "GET", `/repos${qs}`); |
| 300 | }; |
| 301 | |
| 302 | export const getRepo = (c: Connection, owner: string, repo: string) => |
| 303 | request<Repo>(c, "GET", `/repos/${seg(owner)}/${seg(repo)}`); |
| 304 | |
| 305 | export const createRepo = ( |
| 306 | c: Connection, |
| 307 | payload: { |
| 308 | owner: string; |
| 309 | name: string; |
| 310 | description?: string; |
| 311 | public?: boolean; |
| 312 | }, |
| 313 | ) => request<RepoCreated>(c, "POST", `/repos`, payload); |
| 314 | |
| 315 | export const updateRepo = ( |
| 316 | c: Connection, |
| 317 | owner: string, |
| 318 | repo: string, |
| 319 | payload: { |
| 320 | description?: string | null; |
| 321 | defaultBranch?: string; |
| 322 | readOnly?: boolean; |
| 323 | public?: boolean; |
| 324 | }, |
| 325 | ) => request<Repo>(c, "PATCH", `/repos/${seg(owner)}/${seg(repo)}`, payload); |
| 326 | |
| 327 | export const deleteRepo = (c: Connection, owner: string, repo: string) => |
| 328 | request<unknown>(c, "DELETE", `/repos/${seg(owner)}/${seg(repo)}`); |
| 329 | |
| 330 | export const compactRepo = (c: Connection, owner: string, repo: string) => |
| 331 | request<unknown>(c, "POST", `/repos/${seg(owner)}/${seg(repo)}/compact`); |
| 332 | |
| 333 | // ── refs ──────────────────────────────────────────────────────────────────── |
| 334 | |
| 335 | export const listRefs = (c: Connection, owner: string, repo: string) => |
| 336 | request<{ head: string | null; refs: Ref[] }>( |
| 337 | c, |
| 338 | "GET", |
| 339 | `/repos/${seg(owner)}/${seg(repo)}/refs`, |
| 340 | ); |
| 341 | |
| 342 | // ── objects ───────────────────────────────────────────────────────────────── |
| 343 | |
| 344 | export const getCommit = ( |
| 345 | c: Connection, |
| 346 | owner: string, |
| 347 | repo: string, |
| 348 | oid: string, |
| 349 | ) => |
| 350 | request<CommitInfo>( |
| 351 | c, |
| 352 | "GET", |
| 353 | `/repos/${seg(owner)}/${seg(repo)}/commits/${seg(oid)}`, |
| 354 | ); |
| 355 | |
| 356 | export const getLog = ( |
| 357 | c: Connection, |
| 358 | owner: string, |
| 359 | repo: string, |
| 360 | query?: { ref?: string; cursor?: string; limit?: number }, |
| 361 | ): Promise<Page<CommitInfo>> => { |
| 362 | const params = new URLSearchParams(); |
| 363 | if (query?.ref) params.set("ref", query.ref); |
| 364 | if (query?.cursor) params.set("cursor", query.cursor); |
| 365 | if (query?.limit) params.set("limit", String(query.limit)); |
| 366 | const qs = params.size > 0 ? `?${params}` : ""; |
| 367 | return request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/log${qs}`); |
| 368 | }; |
| 369 | |
| 370 | export const getTree = ( |
| 371 | c: Connection, |
| 372 | owner: string, |
| 373 | repo: string, |
| 374 | oid: string, |
| 375 | ) => |
| 376 | request<{ oid: string; entries: TreeEntry[] }>( |
| 377 | c, |
| 378 | "GET", |
| 379 | `/repos/${seg(owner)}/${seg(repo)}/trees/${seg(oid)}`, |
| 380 | ); |
| 381 | |
| 382 | /** Raw file bytes at `ref` + `path` (the streaming non-JSON route). */ |
| 383 | export const getFile = async ( |
| 384 | c: Connection, |
| 385 | owner: string, |
| 386 | repo: string, |
| 387 | options: { ref?: string; path: string }, |
| 388 | ): Promise<Uint8Array> => { |
| 389 | const params = new URLSearchParams({ path: options.path }); |
| 390 | if (options.ref) params.set("ref", options.ref); |
| 391 | const res = await fetch( |
| 392 | `${c.url}/api/v1/repos/${seg(owner)}/${seg(repo)}/file?${params}`, |
| 393 | { |
| 394 | credentials: "include", |
| 395 | }, |
| 396 | ); |
| 397 | if (!res.ok) throw await parseError(res); |
| 398 | return new Uint8Array(await res.arrayBuffer()); |
| 399 | }; |
| 400 | |
| 401 | /** Changed files of a commit vs its first parent (empty tree for a root). */ |
| 402 | export const getCommitDiff = ( |
| 403 | c: Connection, |
| 404 | owner: string, |
| 405 | repo: string, |
| 406 | oid: string, |
| 407 | ) => |
| 408 | request<CommitDiff>( |
| 409 | c, |
| 410 | "GET", |
| 411 | `/repos/${seg(owner)}/${seg(repo)}/commits/${seg(oid)}/diff`, |
| 412 | ); |
| 413 | |
| 414 | /** |
| 415 | * Three-dot comparison of two revisions (short/full refname or 40-hex |
| 416 | * oid; annotated tags peeled): merge base, ahead/behind, head-side |
| 417 | * commits, and the mergeBase..head file diff. |
| 418 | */ |
| 419 | export const compareCommits = ( |
| 420 | c: Connection, |
| 421 | owner: string, |
| 422 | repo: string, |
| 423 | query: { base: string; head: string }, |
| 424 | ) => { |
| 425 | const params = new URLSearchParams({ base: query.base, head: query.head }); |
| 426 | return request<Comparison>( |
| 427 | c, |
| 428 | "GET", |
| 429 | `/repos/${seg(owner)}/${seg(repo)}/compare?${params}`, |
| 430 | ); |
| 431 | }; |
| 432 | |
| 433 | /** JSON blob endpoint serves ≤ 1 MiB only (422 beyond; use /raw). */ |
| 434 | const MAX_JSON_BLOB = 1024 * 1024; |
| 435 | |
| 436 | /** Raw blob bytes by oid (the streaming non-JSON route, any size). */ |
| 437 | const getBlobRaw = async ( |
| 438 | c: Connection, |
| 439 | owner: string, |
| 440 | repo: string, |
| 441 | oid: string, |
| 442 | ): Promise<Uint8Array> => { |
| 443 | const res = await fetch( |
| 444 | `${c.url}/api/v1/repos/${seg(owner)}/${seg(repo)}/blobs/${seg(oid)}/raw`, |
| 445 | { credentials: "include" }, |
| 446 | ); |
| 447 | if (!res.ok) throw await parseError(res); |
| 448 | return new Uint8Array(await res.arrayBuffer()); |
| 449 | }; |
| 450 | |
| 451 | /** |
| 452 | * Blob bytes by oid. Uses the JSON base64 route for blobs ≤ 1 MiB and |
| 453 | * falls back to `/raw` for bigger ones — pass `size` (known from a |
| 454 | * {@link DiffEntry}) to skip the doomed JSON attempt entirely. |
| 455 | */ |
| 456 | export const getBlob = async ( |
| 457 | c: Connection, |
| 458 | owner: string, |
| 459 | repo: string, |
| 460 | oid: string, |
| 461 | options?: { size?: number }, |
| 462 | ): Promise<Uint8Array> => { |
| 463 | if (options?.size !== undefined && options.size > MAX_JSON_BLOB) { |
| 464 | return getBlobRaw(c, owner, repo, oid); |
| 465 | } |
| 466 | try { |
| 467 | const json = await request<{ |
| 468 | oid: string; |
| 469 | size: number; |
| 470 | encoding: "base64"; |
| 471 | content: string; |
| 472 | }>(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/blobs/${seg(oid)}`); |
| 473 | const binary = atob(json.content); |
| 474 | const bytes = new Uint8Array(binary.length); |
| 475 | for (let index = 0; index < binary.length; index++) { |
| 476 | bytes[index] = binary.charCodeAt(index); |
| 477 | } |
| 478 | return bytes; |
| 479 | } catch (cause) { |
| 480 | // ObjectTooLarge — the size hint was absent or stale; stream it raw. |
| 481 | if (cause instanceof ApiError && cause.tag === "ObjectTooLarge") { |
| 482 | return getBlobRaw(c, owner, repo, oid); |
| 483 | } |
| 484 | throw cause; |
| 485 | } |
| 486 | }; |
| 487 | |
| 488 | // ── pull requests ─────────────────────────────────────────────────────────── |
| 489 | |
| 490 | export type PullState = "open" | "closed" | "merged"; |
| 491 | |
| 492 | /** Why `mergeable` is what it is (see {@link PullDetail}). */ |
| 493 | export type MergeableReason = |
| 494 | | "ff" |
| 495 | | "merge-commit" |
| 496 | | "conflict" |
| 497 | | "up-to-date" |
| 498 | | "unknown"; |
| 499 | |
| 500 | /** |
| 501 | * A pull request. PRs track **live** branches by ref name — the record |
| 502 | * stores intent + lifecycle, never a diff snapshot; diff and mergeability |
| 503 | * are recomputed from current ref tips on every read. |
| 504 | */ |
| 505 | export interface Pull { |
| 506 | /** Per-repo monotonic PR number (1-based, never reused). */ |
| 507 | number: number; |
| 508 | title: string; |
| 509 | body: string | null; |
| 510 | /** Full base ref name, e.g. `refs/heads/main`. */ |
| 511 | baseRef: string; |
| 512 | /** Full head ref name, e.g. `refs/heads/feature`. */ |
| 513 | headRef: string; |
| 514 | state: PullState; |
| 515 | /** Epoch milliseconds. */ |
| 516 | createdAt: number; |
| 517 | updatedAt: number; |
| 518 | /** Epoch milliseconds; `null` unless `state` is `merged`. */ |
| 519 | mergedAt: number | null; |
| 520 | /** FF: the head tip; the merge commit otherwise. Set iff merged. */ |
| 521 | mergeCommit: string | null; |
| 522 | } |
| 523 | |
| 524 | /** |
| 525 | * PR detail = the row + live computed compare fields. Live fields are |
| 526 | * `null` when uncomputable: a missing base/head branch, a saturated |
| 527 | * ancestor walk, or a merged PR (its record is `mergeCommit`). |
| 528 | */ |
| 529 | export interface PullDetail extends Pull { |
| 530 | /** Current tip of `baseRef`; `null` if the branch is gone. */ |
| 531 | baseOid: string | null; |
| 532 | /** Current tip of `headRef`; `null` if the branch is gone. */ |
| 533 | headOid: string | null; |
| 534 | mergeBase: string | null; |
| 535 | /** Commits on head not on base (`null` when the walk saturates). */ |
| 536 | aheadBy: number | null; |
| 537 | /** Commits on base not on head. */ |
| 538 | behindBy: number | null; |
| 539 | /** |
| 540 | * `true` = FF-able or trivially merge-able; `false` = conflicting |
| 541 | * paths or up-to-date; `null` = uncomputable. |
| 542 | */ |
| 543 | mergeable: boolean | null; |
| 544 | mergeableReason: MergeableReason | null; |
| 545 | } |
| 546 | |
| 547 | /** Response of a successful PR merge. */ |
| 548 | export interface MergeResult { |
| 549 | method: "ff" | "merge-commit"; |
| 550 | /** The oid the base ref now points at. */ |
| 551 | oid: string; |
| 552 | /** The PR after the merge (`state: "merged"`). */ |
| 553 | pull: Pull; |
| 554 | } |
| 555 | |
| 556 | /** Lists PRs, newest first. `state` defaults to `open` server-side. */ |
| 557 | export const listPulls = ( |
| 558 | c: Connection, |
| 559 | owner: string, |
| 560 | repo: string, |
| 561 | query?: { |
| 562 | state?: PullState | "all"; |
| 563 | cursor?: string; |
| 564 | limit?: number; |
| 565 | }, |
| 566 | ): Promise<Page<Pull>> => { |
| 567 | const params = new URLSearchParams(); |
| 568 | if (query?.state) params.set("state", query.state); |
| 569 | if (query?.cursor) params.set("cursor", query.cursor); |
| 570 | if (query?.limit) params.set("limit", String(query.limit)); |
| 571 | const qs = params.size > 0 ? `?${params}` : ""; |
| 572 | return request(c, "GET", `/repos/${seg(owner)}/${seg(repo)}/pulls${qs}`); |
| 573 | }; |
| 574 | |
| 575 | /** Reads one PR with live compare fields (ahead/behind/mergeable). */ |
| 576 | export const getPull = ( |
| 577 | c: Connection, |
| 578 | owner: string, |
| 579 | repo: string, |
| 580 | number: number, |
| 581 | ) => |
| 582 | request<PullDetail>( |
| 583 | c, |
| 584 | "GET", |
| 585 | `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}`, |
| 586 | ); |
| 587 | |
| 588 | /** Opens a PR. `base`/`head` accept short (`main`) or full branch names. */ |
| 589 | export const createPull = ( |
| 590 | c: Connection, |
| 591 | owner: string, |
| 592 | repo: string, |
| 593 | payload: { title: string; body?: string; base: string; head: string }, |
| 594 | ) => |
| 595 | request<Pull>(c, "POST", `/repos/${seg(owner)}/${seg(repo)}/pulls`, payload); |
| 596 | |
| 597 | /** Patches title/body, or closes/reopens via `state`. */ |
| 598 | export const updatePull = ( |
| 599 | c: Connection, |
| 600 | owner: string, |
| 601 | repo: string, |
| 602 | number: number, |
| 603 | payload: { |
| 604 | title?: string; |
| 605 | body?: string | null; |
| 606 | state?: "open" | "closed"; |
| 607 | }, |
| 608 | ) => |
| 609 | request<Pull>( |
| 610 | c, |
| 611 | "PATCH", |
| 612 | `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}`, |
| 613 | payload, |
| 614 | ); |
| 615 | |
| 616 | /** |
| 617 | * Merges an open PR: fast-forward when possible, else a merge commit iff |
| 618 | * the three-way tree merge is trivial. `expectedHeadOid` guards against |
| 619 | * a force-push racing the merge (409 RefConflict when stale). |
| 620 | */ |
| 621 | export const mergePull = ( |
| 622 | c: Connection, |
| 623 | owner: string, |
| 624 | repo: string, |
| 625 | number: number, |
| 626 | payload?: { message?: string; expectedHeadOid?: string }, |
| 627 | ) => |
| 628 | request<MergeResult>( |
| 629 | c, |
| 630 | "POST", |
| 631 | `/repos/${seg(owner)}/${seg(repo)}/pulls/${number}/merge`, |
| 632 | payload ?? {}, |
| 633 | ); |