1/**
2 * The `pulls` group: pull-request lifecycle + merge (DESIGN.md §5).
3 *
4 * PRs are same-repo branch PRs (no cross-fork), tracking **live** branches
5 * by ref name: diff and mergeability are computed on read from current ref
6 * tips. Merging fast-forwards when possible; otherwise it creates a merge
7 * commit iff the three-way tree merge is trivial (no path touched on both
8 * sides relative to the merge base) — a conflicting PR is a typed 409, the
9 * server never writes conflict markers.
10 */
11import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
12import * as Schema from "effect/Schema";
13import * as HttpApiGroup from "effect/http-api/HttpApiGroup";
14import {
15 BranchMissing,
16 MergeConflict,
17 MergeResult,
18 NothingToMerge,
19 Oid,
20 OwnerName,
21 Paginated,
22 Pull,
23 PullDetail,
24 PullExists,
25 PullNotFound,
26 PullStateConflict,
27 ReadOnlyRepo,
28 RefConflict,
29 RepoName,
30 RepoNotFound,
31 RepoPath,
32 ValidationError,
33 PushDenied,
34} from "./Schema.ts";
35
36/** `(owner, repo, number)` path segments for single-PR endpoints. */
37export const PullPath = Schema.Struct({
38 owner: OwnerName,
39 repo: RepoName,
40 /** The PR number (path segment; decoded from its string form). */
41 number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
42});
43
44/**
45 * Opens a PR. `base`/`head` accept short (`main`) or full
46 * (`refs/heads/main`) branch names — only branches are legal (no tags).
47 */
48export const CreatePull = HttpApiEndpoint.post(
49 "create",
50 "/repos/:owner/:repo/pulls",
51 {
52 params: RepoPath,
53 payload: Schema.Struct({
54 title: Schema.String.check(Schema.isMinLength(1)),
55 body: Schema.optional(Schema.String),
56 /** Target branch, short or full name. */
57 base: Schema.String,
58 /** Source branch, short or full name. Must differ from `base`. */
59 head: Schema.String,
60 }),
61 success: Pull,
62 error: [RepoNotFound, BranchMissing, PullExists, ValidationError],
63 },
64);
65
66/** Lists PRs, newest first. `state` defaults to `open`. Anonymous on public repos. */
67export const ListPulls = HttpApiEndpoint.get(
68 "list",
69 "/repos/:owner/:repo/pulls",
70 {
71 params: RepoPath,
72 query: Schema.Struct({
73 /** Filter by lifecycle state. @default open */
74 state: Schema.optional(
75 Schema.Literals(["open", "closed", "merged", "all"]),
76 ),
77 cursor: Schema.optional(Schema.String),
78 limit: Schema.optional(
79 Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })),
80 ),
81 }),
82 success: Paginated(Pull),
83 error: [RepoNotFound],
84 },
85);
86
87/** Reads one PR with live compare fields (aheadBy/behindBy/mergeable). */
88export const GetPull = HttpApiEndpoint.get(
89 "get",
90 "/repos/:owner/:repo/pulls/:number",
91 {
92 params: PullPath,
93 success: PullDetail,
94 error: [RepoNotFound, PullNotFound],
95 },
96);
97
98/** Patches title/body, or closes/reopens via `state`. */
99export const UpdatePull = HttpApiEndpoint.patch(
100 "update",
101 "/repos/:owner/:repo/pulls/:number",
102 {
103 params: PullPath,
104 payload: Schema.Struct({
105 title: Schema.optional(Schema.String.check(Schema.isMinLength(1))),
106 /** `null` clears the body. */
107 body: Schema.optional(Schema.NullOr(Schema.String)),
108 /**
109 * `closed` closes an open PR; `open` reopens a closed one. Merged
110 * PRs reject both (409).
111 */
112 state: Schema.optional(Schema.Literals(["open", "closed"])),
113 }),
114 success: Pull,
115 error: [RepoNotFound, PullNotFound, PullStateConflict],
116 },
117);
118
119/**
120 * Merges an open PR: fast-forward when possible, else a merge commit iff
121 * the three-way tree merge is trivial.
122 */
123export const MergePull = HttpApiEndpoint.post(
124 "merge",
125 "/repos/:owner/:repo/pulls/:number/merge",
126 {
127 params: PullPath,
128 payload: Schema.Struct({
129 /**
130 * Merge-commit message; defaults to
131 * `Merge pull request #N from <head>`. Ignored on fast-forward.
132 */
133 message: Schema.optional(Schema.String),
134 /**
135 * Race guard: fail RefConflict if the head tip moved (force-push)
136 * since the caller inspected it.
137 */
138 expectedHeadOid: Schema.optional(Oid),
139 }),
140 success: MergeResult,
141 error: [
142 PushDenied,
143 RepoNotFound,
144 PullNotFound,
145 PullStateConflict,
146 BranchMissing,
147 NothingToMerge,
148 MergeConflict,
149 RefConflict,
150 ReadOnlyRepo,
151 ],
152 },
153);
154
155/** The `pulls` group, mounted at `/api/v1`. */
156export class Pulls extends HttpApiGroup.make("pulls")
157 .add(CreatePull, ListPulls, GetPull, UpdatePull, MergePull)
158 .prefix("/api/v1") {}