| 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 | */ |
| 11 | import { useEffect, useState } from "react"; |
| 12 | import { |
| 13 | ApiError, |
| 14 | compareCommits, |
| 15 | getPull, |
| 16 | mergePull, |
| 17 | updatePull, |
| 18 | type Comparison, |
| 19 | type PullDetail, |
| 20 | } from "../client.ts"; |
| 21 | import { |
| 22 | Button, |
| 23 | CopyButton, |
| 24 | ErrorBox, |
| 25 | Markdown, |
| 26 | Spinner, |
| 27 | } from "../components.tsx"; |
| 28 | import { shortOid, subject, timeAgo } from "../format.ts"; |
| 29 | import { href, Link } from "../router.tsx"; |
| 30 | import FileDiffList from "./DiffList.tsx"; |
| 31 | import { PullStateBadge, shortRef } from "./Pulls.tsx"; |
| 32 | import type { RepoContext } from "./Repo.tsx"; |
| 33 | |
| 34 | // ── typed-error presentation ──────────────────────────────────────────────── |
| 35 | |
| 36 | /** Maps the service's typed merge/update errors to inline messages. */ |
| 37 | const 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 | |
| 63 | const 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 | |
| 97 | type CompareState = Comparison | "unavailable" | null; // null = loading |
| 98 | |
| 99 | const 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 | |
| 427 | export default PullPage; |