1/**
2 * The pull-request view (`/:owner/:repo/pulls/:number`): GitHub-style
3 * title/state header, branch line, Markdown body, mergeability box with
4 * Merge/Close/Reopen actions, and tabbed Commits + Files changed from
5 * the three-dot `/compare` endpoint.
6 *
7 * `React.lazy`-loaded from `Repo.tsx` — the Files-changed tab renders
8 * through the shared `FileDiffList` (diffs+shiki runtime), which must
9 * stay out of the initial bundle.
10 */
11import { useEffect, useState } from "react";
12import {
13 ApiError,
14 compareCommits,
15 getPull,
16 mergePull,
17 updatePull,
18 type Comparison,
19 type PullDetail,
20} from "../client.ts";
21import {
22 Button,
23 CopyButton,
24 ErrorBox,
25 Markdown,
26 Spinner,
27} from "../components.tsx";
28import { shortOid, subject, timeAgo } from "../format.ts";
29import { href, Link } from "../router.tsx";
30import FileDiffList from "./DiffList.tsx";
31import { PullStateBadge, shortRef } from "./Pulls.tsx";
32import type { RepoContext } from "./Repo.tsx";
33
34// ── typed-error presentation ────────────────────────────────────────────────
35
36/** Maps the service's typed merge/update errors to inline messages. */
37const friendlyError = (cause: unknown): string => {
38 if (!(cause instanceof ApiError)) {
39 return cause instanceof Error ? cause.message : String(cause);
40 }
41 switch (cause.tag) {
42 case "MergeConflict":
43 return "Merge conflict — files were changed on both branches relative to the merge base. Resolve the conflict locally and push, or close this pull request.";
44 case "BranchMissing":
45 return "The base or head branch no longer exists.";
46 case "NothingToMerge":
47 return "Nothing to merge — the head branch is already part of the base branch.";
48 case "RefConflict":
49 return "A branch moved while merging (force-push or concurrent update) — review the new tip and try again.";
50 case "PullStateConflict":
51 return "The pull request's state changed underneath this page — it has been refreshed.";
52 case "ReadOnlyRepo":
53 return "This repository is read-only — merging is disabled.";
54 case "Forbidden":
55 return "Your token does not have write access to this repository.";
56 default:
57 return `${cause.tag}: ${cause.message}`;
58 }
59};
60
61// ── mergeability box ────────────────────────────────────────────────────────
62
63const mergeStatus = (
64 detail: PullDetail,
65): { tone: "success" | "danger" | "muted"; text: string } => {
66 if (detail.mergeable === true) {
67 return {
68 tone: "success",
69 text:
70 detail.mergeableReason === "ff"
71 ? "This branch has no conflicts with the base branch — it can be fast-forwarded."
72 : "This branch has no conflicts with the base branch — a merge commit will be created.",
73 };
74 }
75 if (detail.mergeable === false) {
76 return detail.mergeableReason === "up-to-date"
77 ? {
78 tone: "muted",
79 text: "Nothing to merge — the head branch is already part of the base branch.",
80 }
81 : {
82 tone: "danger",
83 text: "This branch has conflicts with the base branch — the same files were changed on both sides.",
84 };
85 }
86 return {
87 tone: "muted",
88 text:
89 detail.baseOid === null || detail.headOid === null
90 ? "Mergeability unknown — the base or head branch no longer exists."
91 : "Mergeability could not be computed for this pull request.",
92 };
93};
94
95// ── the page ────────────────────────────────────────────────────────────────
96
97type CompareState = Comparison | "unavailable" | null; // null = loading
98
99const PullPage = ({
100 context,
101 number,
102}: {
103 context: RepoContext;
104 number: number;
105}) => {
106 const [detail, setDetail] = useState<PullDetail | null>(null);
107 const [compare, setCompare] = useState<CompareState>(null);
108 const [error, setError] = useState<unknown>(null);
109 const [tab, setTab] = useState<"files" | "commits">("files");
110 const [actionError, setActionError] = useState<string | null>(null);
111 const [busy, setBusy] = useState(false);
112 const [generation, setGeneration] = useState(0);
113 const { connection, repo } = context;
114 const signedIn = context.user !== null;
115
116 useEffect(() => {
117 let cancelled = false;
118 setDetail(null);
119 setCompare(null);
120 setError(null);
121 void (async () => {
122 try {
123 const pull = await getPull(connection, repo.owner, repo.name, number);
124 if (cancelled) return;
125 setDetail(pull);
126 // Live compare only when both branches still exist (merged PRs
127 // return null tips — their record is the merge commit).
128 if (pull.baseOid !== null && pull.headOid !== null) {
129 const comparison = await compareCommits(
130 connection,
131 repo.owner,
132 repo.name,
133 { base: pull.baseRef, head: pull.headRef },
134 );
135 if (!cancelled) setCompare(comparison);
136 } else {
137 setCompare("unavailable");
138 }
139 } catch (cause) {
140 if (!cancelled) setError(cause);
141 }
142 })();
143 return () => {
144 cancelled = true;
145 };
146 // eslint-disable-next-line react-hooks/exhaustive-deps
147 }, [number, repo.repoId, generation]);
148
149 const act = (run: () => Promise<unknown>) => {
150 setBusy(true);
151 setActionError(null);
152 void run()
153 .then(() => setGeneration((value) => value + 1))
154 .catch((cause: unknown) => {
155 setActionError(friendlyError(cause));
156 // The state/tips this page shows are stale — reload them so the
157 // message matches what the user now sees.
158 if (
159 cause instanceof ApiError &&
160 (cause.tag === "PullStateConflict" || cause.tag === "RefConflict")
161 ) {
162 setGeneration((value) => value + 1);
163 }
164 })
165 .finally(() => setBusy(false));
166 };
167
168 if (error != null) return <ErrorBox error={error} />;
169 if (detail === null) return <Spinner />;
170
171 const { owner, name } = repo;
172 const status = mergeStatus(detail);
173 const boxTone =
174 status.tone === "success"
175 ? "border-success/40 bg-success/5"
176 : status.tone === "danger"
177 ? "border-danger/40 bg-danger/5"
178 : "border-border-muted bg-canvas-subtle";
179
180 return (
181 <div>
182 {/* header */}
183 <div className="mb-4 border-b border-border-muted pb-4">
184 <h1 className="text-xl font-semibold">
185 {detail.title}{" "}
186 <span className="font-normal text-fg-muted">#{detail.number}</span>
187 </h1>
188 <div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-fg-muted">
189 <PullStateBadge state={detail.state} />
190 {detail.state === "merged" ? (
191 <span>
192 merged {timeAgo(detail.mergedAt ?? detail.updatedAt)}
193 {detail.mergeCommit !== null && (
194 <>
195 {" as "}
196 <Link
197 to={href(owner, name, "commit", detail.mergeCommit)}
198 className="font-mono text-accent hover:underline"
199 >
200 {shortOid(detail.mergeCommit)}
201 </Link>
202 </>
203 )}
204 {" — "}
205 <code className="font-mono">{shortRef(detail.headRef)}</code>
206 {" into "}
207 <code className="font-mono">{shortRef(detail.baseRef)}</code>
208 </span>
209 ) : (
210 <span>
211 wants to merge{" "}
212 <code className="rounded bg-canvas-subtle px-1.5 py-0.5 font-mono">
213 {shortRef(detail.headRef)}
214 </code>{" "}
215 into{" "}
216 <code className="rounded bg-canvas-subtle px-1.5 py-0.5 font-mono">
217 {shortRef(detail.baseRef)}
218 </code>
219 {" · opened "}
220 {timeAgo(detail.createdAt)}
221 {detail.aheadBy !== null && detail.behindBy !== null && (
222 <>
223 {" · "}
224 {detail.aheadBy} commit{detail.aheadBy === 1 ? "" : "s"}{" "}
225 ahead, {detail.behindBy} behind
226 </>
227 )}
228 </span>
229 )}
230 </div>
231 </div>
232
233 {/* body */}
234 {detail.body !== null && detail.body.trim().length > 0 && (
235 <div className="mb-4 rounded-md border border-border-muted p-4">
236 <Markdown source={detail.body} />
237 </div>
238 )}
239
240 {/* mergeability + actions */}
241 {detail.state === "open" && (
242 <div className={`mb-4 rounded-md border px-4 py-3 text-sm ${boxTone}`}>
243 <p
244 className={
245 status.tone === "success"
246 ? "text-success"
247 : status.tone === "danger"
248 ? "text-danger"
249 : "text-fg-muted"
250 }
251 >
252 {status.text}
253 </p>
254 {signedIn && (
255 <div className="mt-3 flex flex-wrap items-center gap-2">
256 <Button
257 kind="primary"
258 disabled={busy || detail.mergeable !== true}
259 onClick={() =>
260 act(() =>
261 mergePull(connection, owner, name, number, {
262 // Race guard: fail 409 RefConflict if the head tip
263 // moved since this page loaded.
264 ...(detail.headOid !== null
265 ? { expectedHeadOid: detail.headOid }
266 : {}),
267 }),
268 )
269 }
270 >
271 {busy ? "Working…" : "Merge pull request"}
272 </Button>
273 <Button
274 disabled={busy}
275 onClick={() =>
276 act(() =>
277 updatePull(connection, owner, name, number, {
278 state: "closed",
279 }),
280 )
281 }
282 >
283 Close
284 </Button>
285 {detail.mergeable !== true && (
286 <span className="text-xs text-fg-muted">
287 merging is disabled until the branch is mergeable
288 </span>
289 )}
290 </div>
291 )}
292 {actionError !== null && (
293 <p className="mt-2 text-sm text-danger">{actionError}</p>
294 )}
295 </div>
296 )}
297 {detail.state === "closed" && (
298 <div className="mb-4 rounded-md border border-border-muted bg-canvas-subtle px-4 py-3 text-sm">
299 <p className="text-fg-muted">
300 This pull request is closed without being merged.
301 </p>
302 {signedIn && (
303 <div className="mt-3">
304 <Button
305 disabled={busy}
306 onClick={() =>
307 act(() =>
308 updatePull(connection, owner, name, number, {
309 state: "open",
310 }),
311 )
312 }
313 >
314 Reopen
315 </Button>
316 </div>
317 )}
318 {actionError !== null && (
319 <p className="mt-2 text-sm text-danger">{actionError}</p>
320 )}
321 </div>
322 )}
323
324 {/* compare: commits + files changed */}
325 {compare === null ? (
326 <Spinner />
327 ) : compare === "unavailable" ? (
328 <div className="rounded-md border border-border-muted px-4 py-8 text-center text-sm text-fg-muted">
329 {detail.state === "merged" && detail.mergeCommit !== null ? (
330 <>
331 Live comparison is not available for merged pull requests — see
332 the{" "}
333 <Link
334 to={href(owner, name, "commit", detail.mergeCommit)}
335 className="font-mono text-accent hover:underline"
336 >
337 merge commit
338 </Link>
339 .
340 </>
341 ) : (
342 "Comparison unavailable — the base or head branch no longer exists."
343 )}
344 </div>
345 ) : (
346 <>
347 <div className="mb-4 flex gap-1 border-b border-border-muted text-sm">
348 {(
349 [
350 ["files", `Files changed (${compare.files.length})`],
351 ["commits", `Commits (${compare.commits.length})`],
352 ] as const
353 ).map(([key, label]) => (
354 <button
355 key={key}
356 type="button"
357 onClick={() => setTab(key)}
358 className={`cursor-pointer border-b-2 px-3 py-2 ${
359 tab === key
360 ? "border-tab-active font-semibold"
361 : "border-transparent text-fg-muted hover:border-border-muted"
362 }`}
363 >
364 {label}
365 </button>
366 ))}
367 </div>
368
369 {tab === "commits" ? (
370 <ul className="overflow-hidden rounded-md border border-border-muted">
371 {compare.commits.map((commit) => (
372 <li
373 key={commit.oid}
374 className="flex items-start justify-between gap-4 border-b border-border-muted px-4 py-3 last:border-b-0"
375 >
376 <div className="min-w-0">
377 <Link
378 to={href(owner, name, "commit", commit.oid)}
379 className="text-sm font-semibold hover:text-accent hover:underline"
380 >
381 {subject(commit.message)}
382 </Link>
383 <p className="mt-0.5 text-xs text-fg-muted">
384 {commit.author.name} committed{" "}
385 {timeAgo(commit.author.date * 1000)}
386 </p>
387 </div>
388 <div className="flex shrink-0 items-center gap-1">
389 <Link
390 to={href(owner, name, "commit", commit.oid)}
391 className="rounded border border-border-muted px-2 py-0.5 font-mono text-xs text-accent hover:underline"
392 >
393 {shortOid(commit.oid)}
394 </Link>
395 <CopyButton text={commit.oid} />
396 </div>
397 </li>
398 ))}
399 {compare.commits.length === 0 && (
400 <li className="px-4 py-8 text-center text-sm text-fg-muted">
401 No commits — the head branch is not ahead of the base.
402 </li>
403 )}
404 {compare.commitsTruncated && (
405 <li className="border-t border-border-muted bg-canvas-subtle px-4 py-2 text-center text-xs text-fg-muted">
406 Commit list truncated by the server.
407 </li>
408 )}
409 </ul>
410 ) : (
411 <>
412 {compare.filesTruncated && (
413 <div className="mb-3 rounded-md border border-attention/40 bg-attention/5 px-4 py-2 text-sm text-attention">
414 This pull request changes more files than shown — the list was
415 truncated at {compare.files.length} files by the server.
416 </div>
417 )}
418 <FileDiffList context={context} files={compare.files} />
419 </>
420 )}
421 </>
422 )}
423 </div>
424 );
425};
426
427export default PullPage;