| 1 | /** |
| 2 | * Shared schemas of the git-service REST contract (DESIGN.md §5): |
| 3 | * primitives, the tagged-error taxonomy, and the domain shapes. The |
| 4 | * per-group endpoint declarations live in the sibling modules (`Repos.ts`, |
| 5 | * `Refs.ts`, `Objects.ts`, `Pulls.ts`); the assembled `GitApi` lives in |
| 6 | * `../Api.ts`. |
| 7 | * |
| 8 | * The groups carry no middleware and no auth errors of their own: the |
| 9 | * `HttpApi` you mount them in decides who may call them, and application handlers |
| 10 | * decide which refs may move. |
| 11 | */ |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | // Pulls in the `httpApiStatus` annotation augmentation used by the error |
| 14 | // classes below. |
| 15 | import "effect/http-api/HttpApiSchema"; |
| 16 | |
| 17 | // ───────────────────────────────────────────────────────────────────────────── |
| 18 | // Primitives |
| 19 | // ───────────────────────────────────────────────────────────────────────────── |
| 20 | |
| 21 | /** |
| 22 | * A 40-hex SHA-1 object id, branded so oids never mix with plain strings. |
| 23 | * Widening to SHA-256 later is mechanical because every consumer goes |
| 24 | * through this schema (DESIGN.md §1 "Hashing"). |
| 25 | */ |
| 26 | export const Oid = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/)).pipe( |
| 27 | Schema.brand("Oid"), |
| 28 | ); |
| 29 | |
| 30 | /** The decoded (branded) type of {@link Oid}. */ |
| 31 | export type Oid = typeof Oid.Type; |
| 32 | |
| 33 | /** |
| 34 | * A repository name: 1–100 chars, alphanumeric with `._-` separators, |
| 35 | * must start and end alphanumeric. Case-insensitive match, stored as given. |
| 36 | */ |
| 37 | export const RepoName = Schema.String.check( |
| 38 | Schema.isPattern(/^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/i), |
| 39 | ); |
| 40 | |
| 41 | /** |
| 42 | * An owner (namespace) name. Same grammar as {@link RepoName}. The literal |
| 43 | * owner `api` is reserved (rejected by the Registry at creation) so the |
| 44 | * `/api/v1` prefix can never collide with a repo URL. |
| 45 | */ |
| 46 | export const OwnerName = RepoName; |
| 47 | |
| 48 | /** |
| 49 | * A full ref name (`refs/heads/main`, `refs/tags/v1.0`). Rejects the |
| 50 | * whitespace / `~^:?*[\` characters git itself forbids. HEAD is virtual |
| 51 | * (symref to the default branch) and is never a valid `RefName` here. |
| 52 | */ |
| 53 | export const RefName = Schema.String.check( |
| 54 | Schema.isPattern(/^refs\/[^\s~^:?*\[\\]+$/), |
| 55 | ); |
| 56 | |
| 57 | /** |
| 58 | * Lifecycle status of a repo. Async operations (import/fork/delete) flip |
| 59 | * this field; clients poll `GET /repos/:owner/:repo` until `ready` (or 404 |
| 60 | * once a `deleting` repo's purge completes). There is no jobs API. |
| 61 | */ |
| 62 | export const RepoStatus = Schema.Literals([ |
| 63 | "ready", |
| 64 | "importing", |
| 65 | "forking", |
| 66 | "deleting", |
| 67 | ]); |
| 68 | |
| 69 | /** The decoded type of {@link RepoStatus}. */ |
| 70 | export type RepoStatus = typeof RepoStatus.Type; |
| 71 | |
| 72 | // ───────────────────────────────────────────────────────────────────────────── |
| 73 | // Error taxonomy (tagged, status-annotated) |
| 74 | // ───────────────────────────────────────────────────────────────────────────── |
| 75 | |
| 76 | /** 403 — the repo is flagged `readOnly`; writes are rejected. */ |
| 77 | export class ReadOnlyRepo extends Schema.TaggedError<ReadOnlyRepo>()( |
| 78 | "ReadOnlyRepo", |
| 79 | {}, |
| 80 | { httpApiStatus: 403 }, |
| 81 | ) {} |
| 82 | |
| 83 | /** 403 — application policy refused a proposed ref update. */ |
| 84 | export class PushDenied extends Schema.TaggedError<PushDenied>()( |
| 85 | "PushDenied", |
| 86 | { ref: Schema.String, reason: Schema.String }, |
| 87 | { httpApiStatus: 403 }, |
| 88 | ) {} |
| 89 | |
| 90 | /** 404 — no repo at `owner/repo` (or it finished deleting). */ |
| 91 | export class RepoNotFound extends Schema.TaggedError<RepoNotFound>()( |
| 92 | "RepoNotFound", |
| 93 | { owner: Schema.String, repo: Schema.String }, |
| 94 | { httpApiStatus: 404 }, |
| 95 | ) {} |
| 96 | |
| 97 | /** 409 — a repo already exists at `owner/repo`. */ |
| 98 | export class RepoAlreadyExists extends Schema.TaggedError<RepoAlreadyExists>()( |
| 99 | "RepoAlreadyExists", |
| 100 | { owner: Schema.String, repo: Schema.String }, |
| 101 | { httpApiStatus: 409 }, |
| 102 | ) {} |
| 103 | |
| 104 | /** |
| 105 | * 409 — the repo exists but is not `ready` (importing / forking / |
| 106 | * deleting); retry after polling `GET /repos/:owner/:repo`. |
| 107 | */ |
| 108 | export class RepoNotReady extends Schema.TaggedError<RepoNotReady>()( |
| 109 | "RepoNotReady", |
| 110 | { status: RepoStatus }, |
| 111 | { httpApiStatus: 409 }, |
| 112 | ) {} |
| 113 | |
| 114 | /** 404 — the named ref does not exist. */ |
| 115 | export class RefNotFound extends Schema.TaggedError<RefNotFound>()( |
| 116 | "RefNotFound", |
| 117 | { ref: Schema.String }, |
| 118 | { httpApiStatus: 404 }, |
| 119 | ) {} |
| 120 | |
| 121 | /** |
| 122 | * 409 — compare-and-swap failure on a ref write: `expectedOid` did not |
| 123 | * match the ref's current value (`currentOid`, `null` when the ref does |
| 124 | * not exist). |
| 125 | */ |
| 126 | export class RefConflict extends Schema.TaggedError<RefConflict>()( |
| 127 | "RefConflict", |
| 128 | { ref: Schema.String, currentOid: Schema.NullOr(Oid) }, |
| 129 | { httpApiStatus: 409 }, |
| 130 | ) {} |
| 131 | |
| 132 | /** 404 — no object with the given oid in this repo. */ |
| 133 | export class ObjectNotFound extends Schema.TaggedError<ObjectNotFound>()( |
| 134 | "ObjectNotFound", |
| 135 | { oid: Schema.String }, |
| 136 | { httpApiStatus: 404 }, |
| 137 | ) {} |
| 138 | |
| 139 | /** 422 — the object exists but has a different type than the endpoint expects. */ |
| 140 | export class WrongObjectType extends Schema.TaggedError<WrongObjectType>()( |
| 141 | "WrongObjectType", |
| 142 | { oid: Schema.String, expected: Schema.String, actual: Schema.String }, |
| 143 | { httpApiStatus: 422 }, |
| 144 | ) {} |
| 145 | |
| 146 | /** |
| 147 | * 422 — the blob is too large for the base64 JSON endpoint (> 1 MiB); |
| 148 | * use the raw streaming route `GET .../blobs/:oid/raw` instead. |
| 149 | */ |
| 150 | export class ObjectTooLarge extends Schema.TaggedError<ObjectTooLarge>()( |
| 151 | "ObjectTooLarge", |
| 152 | { oid: Schema.String, size: Schema.Number }, |
| 153 | { httpApiStatus: 422 }, |
| 154 | ) {} |
| 155 | |
| 156 | /** |
| 157 | * 422 — the two commits share no common ancestor within the walk bound |
| 158 | * (disjoint histories, or divergence beyond the server's 10k-commit cap; |
| 159 | * shallow imports can also surface this across the shallow boundary). |
| 160 | */ |
| 161 | export class NoMergeBase extends Schema.TaggedError<NoMergeBase>()( |
| 162 | "NoMergeBase", |
| 163 | { base: Oid, head: Oid }, |
| 164 | { httpApiStatus: 422 }, |
| 165 | ) {} |
| 166 | |
| 167 | /** 502 — the async import job failed against the source remote. */ |
| 168 | export class ImportFailed extends Schema.TaggedError<ImportFailed>()( |
| 169 | "ImportFailed", |
| 170 | { reason: Schema.String }, |
| 171 | { httpApiStatus: 502 }, |
| 172 | ) {} |
| 173 | |
| 174 | /** 400 — semantically invalid request (e.g. reserved owner name). */ |
| 175 | export class ValidationError extends Schema.TaggedError<ValidationError>()( |
| 176 | "ValidationError", |
| 177 | { message: Schema.String }, |
| 178 | { httpApiStatus: 400 }, |
| 179 | ) {} |
| 180 | |
| 181 | /** 404 — no pull request with this number. */ |
| 182 | export class PullNotFound extends Schema.TaggedError<PullNotFound>()( |
| 183 | "PullNotFound", |
| 184 | { number: Schema.Number }, |
| 185 | { httpApiStatus: 404 }, |
| 186 | ) {} |
| 187 | |
| 188 | /** 422 — the PR's base or head branch does not exist (deleted after open). */ |
| 189 | export class BranchMissing extends Schema.TaggedError<BranchMissing>()( |
| 190 | "BranchMissing", |
| 191 | { ref: Schema.String }, |
| 192 | { httpApiStatus: 422 }, |
| 193 | ) {} |
| 194 | |
| 195 | /** 409 — an open PR for this base/head pair already exists. */ |
| 196 | export class PullExists extends Schema.TaggedError<PullExists>()( |
| 197 | "PullExists", |
| 198 | { number: Schema.Number }, |
| 199 | { httpApiStatus: 409 }, |
| 200 | ) {} |
| 201 | |
| 202 | /** |
| 203 | * 409 — the operation is invalid in the PR's current state (e.g. merge a |
| 204 | * merged PR, reopen a merged PR). |
| 205 | */ |
| 206 | export class PullStateConflict extends Schema.TaggedError<PullStateConflict>()( |
| 207 | "PullStateConflict", |
| 208 | { |
| 209 | number: Schema.Number, |
| 210 | state: Schema.Literals(["open", "closed", "merged"]), |
| 211 | }, |
| 212 | { httpApiStatus: 409 }, |
| 213 | ) {} |
| 214 | |
| 215 | /** 422 — head is already reachable from base; there is nothing to merge. */ |
| 216 | export class NothingToMerge extends Schema.TaggedError<NothingToMerge>()( |
| 217 | "NothingToMerge", |
| 218 | { number: Schema.Number }, |
| 219 | { httpApiStatus: 422 }, |
| 220 | ) {} |
| 221 | |
| 222 | /** |
| 223 | * 409 — the three-way merge is not trivial: these paths changed on both |
| 224 | * sides relative to the merge base. |
| 225 | */ |
| 226 | export class MergeConflict extends Schema.TaggedError<MergeConflict>()( |
| 227 | "MergeConflict", |
| 228 | { |
| 229 | number: Schema.Number, |
| 230 | /** Conflicting paths, capped at 20 (the full set may be larger). */ |
| 231 | paths: Schema.Array(Schema.String), |
| 232 | }, |
| 233 | { httpApiStatus: 409 }, |
| 234 | ) {} |
| 235 | |
| 236 | // ───────────────────────────────────────────────────────────────────────────── |
| 237 | // Auth middleware |
| 238 | // ───────────────────────────────────────────────────────────────────────────── |
| 239 | |
| 240 | // ───────────────────────────────────────────────────────────────────────────── |
| 241 | // Domain shapes |
| 242 | // ───────────────────────────────────────────────────────────────────────────── |
| 243 | |
| 244 | /** |
| 245 | * A repository resource as returned by the management plane. |
| 246 | */ |
| 247 | /** |
| 248 | * Where a repo's objects currently live (DESIGN.md §12.1). `loose` rows |
| 249 | * hold their bytes in DO SQLite; `packed` objects have been compacted into |
| 250 | * immutable R2 packs; `r2` objects are oversize singletons in R2. |
| 251 | */ |
| 252 | export class ObjectStats extends Schema.Class<ObjectStats>("ObjectStats")({ |
| 253 | /** Objects whose bytes are still in DO SQLite rows. */ |
| 254 | loose: Schema.Number, |
| 255 | /** Commits, trees and tags kept in SQLite rows (never packed). */ |
| 256 | resident: Schema.Number, |
| 257 | /** Objects compacted into R2 packs. */ |
| 258 | packed: Schema.Number, |
| 259 | /** Oversize objects stored as standalone R2 keys. */ |
| 260 | r2: Schema.Number, |
| 261 | /** Total compressed bytes across all locations. */ |
| 262 | bytes: Schema.Number, |
| 263 | }) {} |
| 264 | |
| 265 | /** |
| 266 | * Server-side timing of the most recent push (DESIGN.md §19 phase 0). |
| 267 | * |
| 268 | * Wall-clock `git push` time includes client-side packing and the upload, |
| 269 | * so it bounds server cost rather than measuring it. These are measured |
| 270 | * inside the Durable Object, around the work itself. |
| 271 | */ |
| 272 | export class PushStats extends Schema.Class<PushStats>("PushStats")({ |
| 273 | /** Objects in the pushed pack. */ |
| 274 | objects: Schema.Number, |
| 275 | /** Pack bytes received. */ |
| 276 | bytes: Schema.Number, |
| 277 | /** Parsing + staging the pack (inflate, sha1, SQL). */ |
| 278 | ingestMs: Schema.Number, |
| 279 | /** |
| 280 | * The SQL staging portion of `ingestMs`. `ingestMs - stageMs` is CPU: |
| 281 | * inflate, sha1, delta resolution, commit/tree parsing. The split decides |
| 282 | * whether sharding storage or hoisting parsing is the real win |
| 283 | * (DESIGN.md §19). |
| 284 | */ |
| 285 | stageMs: Schema.Number, |
| 286 | /** The connectivity check. */ |
| 287 | connectivityMs: Schema.Number, |
| 288 | /** The ref CAS transaction. */ |
| 289 | finalizeMs: Schema.Number, |
| 290 | /** Everything the DO did for this push. */ |
| 291 | totalMs: Schema.Number, |
| 292 | /** |
| 293 | * Ingest split by phase (ms): inflate, hash, delta, deflate, copy, sink. |
| 294 | * Meaningful as CPU only where the clock runs during synchronous work |
| 295 | * (local workerd); production workerd freezes `performance.now()` between |
| 296 | * awaits, so there only phases that yield to the event loop accumulate — |
| 297 | * which is itself diagnostic (an async inflate path shows up, a sync one |
| 298 | * reads 0). |
| 299 | */ |
| 300 | phases: Schema.optional(Schema.Record(Schema.String, Schema.Number)), |
| 301 | }) {} |
| 302 | |
| 303 | export class Repo extends Schema.Class<Repo>("Repo")({ |
| 304 | /** Owner (namespace) segment of the repo's URL. */ |
| 305 | owner: OwnerName, |
| 306 | /** Repo name segment of the repo's URL. */ |
| 307 | name: RepoName, |
| 308 | /** |
| 309 | * The stable ULID identity of the repo. Renames never move data because |
| 310 | * the Repo DO id derives from `repoId`, not `owner/name`. |
| 311 | */ |
| 312 | repoId: Schema.String, |
| 313 | /** Short branch name (e.g. `main`) that HEAD symrefs to. */ |
| 314 | defaultBranch: Schema.String, |
| 315 | /** Optional human description. */ |
| 316 | description: Schema.NullOr(Schema.String), |
| 317 | /** When `true`, receive-pack and REST ref writes are rejected. */ |
| 318 | readOnly: Schema.Boolean, |
| 319 | /** |
| 320 | * Public repos are readable (REST reads and `git clone`/`fetch`) |
| 321 | * without any token; writes always require one. |
| 322 | */ |
| 323 | public: Schema.Boolean, |
| 324 | /** Parent `repoId` when this repo is a fork, else `null`. */ |
| 325 | forkOf: Schema.NullOr(Schema.String), |
| 326 | /** Lifecycle status; poll this for async fork/import/delete progress. */ |
| 327 | status: RepoStatus, |
| 328 | /** Creation time, epoch milliseconds. */ |
| 329 | createdAt: Schema.Number, |
| 330 | /** Object storage breakdown (DESIGN.md §12.1). */ |
| 331 | objects: ObjectStats, |
| 332 | /** Server-side timing of the last push, if any (DESIGN.md §19). */ |
| 333 | lastPush: Schema.NullOr(PushStats), |
| 334 | }) {} |
| 335 | |
| 336 | /** |
| 337 | * A ref (branch or tag). `peeled` carries the target commit for annotated |
| 338 | * tags (the advertisement's `^{}` line). |
| 339 | */ |
| 340 | export class Ref extends Schema.Class<Ref>("Ref")({ |
| 341 | /** Full ref name, e.g. `refs/heads/main`. */ |
| 342 | name: RefName, |
| 343 | /** The object the ref points at. */ |
| 344 | oid: Oid, |
| 345 | /** For annotated tags: the peeled (commit) oid. */ |
| 346 | peeled: Schema.optional(Oid), |
| 347 | }) {} |
| 348 | |
| 349 | /** An author/committer/tagger signature line. */ |
| 350 | const Signature = Schema.Struct({ |
| 351 | name: Schema.String, |
| 352 | email: Schema.String, |
| 353 | /** Unix timestamp (seconds). */ |
| 354 | date: Schema.Number, |
| 355 | /** Timezone offset as written, e.g. `+0200`. */ |
| 356 | tz: Schema.String, |
| 357 | }); |
| 358 | |
| 359 | /** Parsed commit metadata for the REST read surface. */ |
| 360 | export class CommitInfo extends Schema.Class<CommitInfo>("CommitInfo")({ |
| 361 | oid: Oid, |
| 362 | /** Root tree of the commit. */ |
| 363 | tree: Oid, |
| 364 | /** Parent commit oids in order. */ |
| 365 | parents: Schema.Array(Oid), |
| 366 | author: Signature, |
| 367 | committer: Signature, |
| 368 | /** Full commit message (verbatim, including trailing newline). */ |
| 369 | message: Schema.String, |
| 370 | }) {} |
| 371 | |
| 372 | /** A single tree entry. `commit` type = gitlink (submodule). */ |
| 373 | export class TreeEntry extends Schema.Class<TreeEntry>("TreeEntry")({ |
| 374 | /** Octal mode string as stored, e.g. `100644`, `40000`, `120000`. */ |
| 375 | mode: Schema.String, |
| 376 | /** Entry name (single path segment). */ |
| 377 | name: Schema.String, |
| 378 | oid: Oid, |
| 379 | /** Entry kind derived from mode. */ |
| 380 | type: Schema.Literals(["blob", "tree", "commit"]), |
| 381 | }) {} |
| 382 | |
| 383 | /** |
| 384 | * File-level change status. v1 has no rename detection (see the DESIGN note |
| 385 | * on the `diff` endpoint in `Objects.ts`); a v1.1 exact-oid pass would add |
| 386 | * `"renamed"` + an `oldPath`. |
| 387 | */ |
| 388 | export const FileStatus = Schema.Literals(["added", "removed", "modified"]); |
| 389 | |
| 390 | /** The decoded type of {@link FileStatus}. */ |
| 391 | export type FileStatus = typeof FileStatus.Type; |
| 392 | |
| 393 | /** |
| 394 | * One changed file in a commit diff or comparison. Content is NOT included — |
| 395 | * clients fetch old/new blobs by oid (`blobs/:oid`, `blobs/:oid/raw`) and |
| 396 | * diff locally. `oldSize`/`newSize` let the client gate binary/oversize |
| 397 | * files without a round trip. Gitlinks (mode 160000) appear as entries whose |
| 398 | * oids are commit oids; clients render "Subproject commit ..." and must not |
| 399 | * fetch them as blobs. |
| 400 | */ |
| 401 | export class DiffEntry extends Schema.Class<DiffEntry>("DiffEntry")({ |
| 402 | /** Full slash-separated path from the repo root. */ |
| 403 | path: Schema.String, |
| 404 | status: FileStatus, |
| 405 | /** Blob oid on the old side (absent for `added`). */ |
| 406 | oldOid: Schema.optional(Oid), |
| 407 | /** Blob oid on the new side (absent for `removed`). */ |
| 408 | newOid: Schema.optional(Oid), |
| 409 | /** Octal mode on the old side, e.g. `100644` (absent for `added`). */ |
| 410 | oldMode: Schema.optional(Schema.String), |
| 411 | /** Octal mode on the new side (absent for `removed`). */ |
| 412 | newMode: Schema.optional(Schema.String), |
| 413 | /** Uncompressed byte size of the old blob (absent for `added`/gitlinks). */ |
| 414 | oldSize: Schema.optional(Schema.Number), |
| 415 | /** Uncompressed byte size of the new blob (absent for `removed`/gitlinks). */ |
| 416 | newSize: Schema.optional(Schema.Number), |
| 417 | }) {} |
| 418 | |
| 419 | /** The changed-file list of one commit vs its first parent. */ |
| 420 | export class CommitDiff extends Schema.Class<CommitDiff>("CommitDiff")({ |
| 421 | oid: Oid, |
| 422 | /** The first parent the diff was computed against; `null` for a root |
| 423 | * commit (diffed against the empty tree — every file is `added`). */ |
| 424 | parent: Schema.NullOr(Oid), |
| 425 | files: Schema.Array(DiffEntry), |
| 426 | /** `true` when the file list was cut at the server cap (1000 files). */ |
| 427 | truncated: Schema.Boolean, |
| 428 | }) {} |
| 429 | |
| 430 | /** GitHub-style three-dot comparison of two commits. */ |
| 431 | export class Comparison extends Schema.Class<Comparison>("Comparison")({ |
| 432 | /** Resolved base commit oid (tags peeled). */ |
| 433 | base: Oid, |
| 434 | /** Resolved head commit oid (tags peeled). */ |
| 435 | head: Oid, |
| 436 | /** Best common ancestor (first both-reachable commit by generation). */ |
| 437 | mergeBase: Oid, |
| 438 | /** Commits reachable from head but not base (`git rev-list --count base..head`). */ |
| 439 | aheadBy: Schema.Number, |
| 440 | /** Commits reachable from base but not head. */ |
| 441 | behindBy: Schema.Number, |
| 442 | /** head-side commits, committer-time descending, capped at 250. */ |
| 443 | commits: Schema.Array(CommitInfo), |
| 444 | commitsTruncated: Schema.Boolean, |
| 445 | /** File diff of mergeBase..head (three-dot, like GitHub compare/PR). */ |
| 446 | files: Schema.Array(DiffEntry), |
| 447 | filesTruncated: Schema.Boolean, |
| 448 | }) {} |
| 449 | |
| 450 | /** Lifecycle state of a pull request. `merged` is terminal. */ |
| 451 | export const PullState = Schema.Literals(["open", "closed", "merged"]); |
| 452 | |
| 453 | /** The decoded type of {@link PullState}. */ |
| 454 | export type PullState = typeof PullState.Type; |
| 455 | |
| 456 | /** |
| 457 | * A pull request. PRs track **live** branches by ref name — the row stores |
| 458 | * intent + lifecycle, never a diff snapshot. Diff and mergeability are |
| 459 | * recomputed from current ref tips on every read (see {@link PullDetail}). |
| 460 | */ |
| 461 | export class Pull extends Schema.Class<Pull>("Pull")({ |
| 462 | /** Per-repo monotonic PR number (1-based, never reused). */ |
| 463 | number: Schema.Int, |
| 464 | title: Schema.String, |
| 465 | body: Schema.NullOr(Schema.String), |
| 466 | /** Full base ref name, e.g. `refs/heads/main`. */ |
| 467 | baseRef: RefName, |
| 468 | /** Full head ref name, e.g. `refs/heads/feature`. */ |
| 469 | headRef: RefName, |
| 470 | state: PullState, |
| 471 | /** Creation time, epoch milliseconds. */ |
| 472 | createdAt: Schema.Number, |
| 473 | /** Last update (title/body/state) time, epoch milliseconds. */ |
| 474 | updatedAt: Schema.Number, |
| 475 | /** Merge time (epoch milliseconds); `null` unless `state` is `merged`. */ |
| 476 | mergedAt: Schema.NullOr(Schema.Number), |
| 477 | /** FF: the head tip; the merge commit otherwise. Set iff state=merged. */ |
| 478 | mergeCommit: Schema.NullOr(Oid), |
| 479 | }) {} |
| 480 | |
| 481 | /** |
| 482 | * PR detail = the row + live computed compare fields. Live fields are |
| 483 | * `null` when uncomputable: a missing base/head branch, a saturated |
| 484 | * ancestor walk, or a merged PR (its record is `mergeCommit`). |
| 485 | */ |
| 486 | export class PullDetail extends Pull.extend<PullDetail>("PullDetail")({ |
| 487 | /** Current tip of `baseRef`; `null` if the branch is gone. */ |
| 488 | baseOid: Schema.NullOr(Oid), |
| 489 | /** Current tip of `headRef`; `null` if the branch is gone. */ |
| 490 | headOid: Schema.NullOr(Oid), |
| 491 | mergeBase: Schema.NullOr(Oid), |
| 492 | /** Commits on head not on base (`null` when the walk saturates). */ |
| 493 | aheadBy: Schema.NullOr(Schema.Int), |
| 494 | /** Commits on base not on head. */ |
| 495 | behindBy: Schema.NullOr(Schema.Int), |
| 496 | /** |
| 497 | * `true` = FF-able or trivially merge-able; `false` = conflicting paths |
| 498 | * or up-to-date; `null` = uncomputable. |
| 499 | */ |
| 500 | mergeable: Schema.NullOr(Schema.Boolean), |
| 501 | /** Why: `"ff" | "merge-commit" | "conflict" | "up-to-date" | "unknown"`. */ |
| 502 | mergeableReason: Schema.NullOr(Schema.String), |
| 503 | }) {} |
| 504 | |
| 505 | /** Response of a successful PR merge. */ |
| 506 | export class MergeResult extends Schema.Class<MergeResult>("MergeResult")({ |
| 507 | /** How the merge landed. */ |
| 508 | method: Schema.Literals(["ff", "merge-commit"]), |
| 509 | /** The oid the base ref now points at. */ |
| 510 | oid: Oid, |
| 511 | /** The PR row after the merge (`state: "merged"`). */ |
| 512 | pull: Pull, |
| 513 | }) {} |
| 514 | |
| 515 | /** |
| 516 | * Response of repo create/fork/import: the repo plus a ready-to-use HTTPS |
| 517 | * remote URL and a bootstrap `write` token, killing the create-then-mint |
| 518 | * round trip. |
| 519 | */ |
| 520 | export class RepoCreated extends Schema.Class<RepoCreated>("RepoCreated")({ |
| 521 | repo: Repo, |
| 522 | /** HTTPS clone URL for the new repo. */ |
| 523 | remote: Schema.String, |
| 524 | }) {} |
| 525 | |
| 526 | /** |
| 527 | * Cursor-paginated wrapper used by list endpoints. |
| 528 | * |
| 529 | * @example |
| 530 | * ```typescript |
| 531 | * const page = Paginated(Repo); // { items: Repo[], nextCursor, hasMore } |
| 532 | * ``` |
| 533 | */ |
| 534 | export const Paginated = <A extends Schema.Top>(items: A) => |
| 535 | Schema.Struct({ |
| 536 | items: Schema.Array(items), |
| 537 | nextCursor: Schema.NullOr(Schema.String), |
| 538 | hasMore: Schema.Boolean, |
| 539 | }); |
| 540 | // ───────────────────────────────────────────────────────────────────────────── |
| 541 | // Shared path-parameter structs |
| 542 | // ───────────────────────────────────────────────────────────────────────────── |
| 543 | |
| 544 | /** `(owner, repo)` path segments shared by repo-scoped endpoints. */ |
| 545 | export const RepoPath = Schema.Struct({ owner: OwnerName, repo: RepoName }); |
| 546 | |
| 547 | /** `(owner, repo, oid)` path segments for object read endpoints. */ |
| 548 | export const RepoOidPath = Schema.Struct({ |
| 549 | owner: OwnerName, |
| 550 | repo: RepoName, |
| 551 | oid: Oid, |
| 552 | }); |