Skip to content

g1t/apps/web/app/components/merge-box.tsx

874 lines33,911 bytesCodeBlame
1/**
2 * The parts of a pull request's merge box that say what stands between it
3 * and a merge: the checks the branch requires and every other check, job by
4 * job, and whether
5 * the change merges cleanly into its target, with what to do when not.
6 */
7import {
8 Check,
9 ChevronRight,
10 CircleAlert,
11 CircleCheck,
12 CircleX,
13 Copy,
14 GitCommitHorizontal,
15 History,
16 LoaderCircle,
17 RotateCw,
18 Sparkles,
19 Terminal,
20 TriangleAlert,
21} from "lucide-react";
22import { useEffect, useMemo, useState } from "react";
23import { Form, Link } from "react-router";
24
25import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, PullBranchUpdate, Pull, RequiredCheck } from "@g1t/contracts";
26
27import { useAddresses } from "../lib/addresses";
28import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
29import { duration } from "./actions";
30import { Elapsed, type Live, useRuns } from "./agents";
31import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui";
32import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
33import { SkeletonLine } from "./ui/skeleton";
34
35// --- Output ---------------------------------------------------------------
36
37/** What the sandbox puts first when it kept only the end of the output. */
38const CUT_SHORT = "… (earlier output not shown)";
39
40/** One run of text in one style, from a line with ANSI colour codes. */
41type Segment = { text: string; className: string };
42
43const ANSI_COLOURS: Record<number, string> = {
44 30: "text-faint",
45 31: "text-danger",
46 32: "text-accent",
47 33: "text-warn",
48 34: "text-info",
49 35: "text-merged",
50 36: "text-info",
51 37: "text-fg",
52 90: "text-faint",
53 91: "text-danger",
54 92: "text-accent",
55 93: "text-warn",
56 94: "text-info",
57 95: "text-merged",
58 96: "text-info",
59 97: "text-fg",
60};
61
62/**
63 * A line's text split where its colour changes. Colours and bold are kept;
64 * every other escape sequence (cursor movement, erasing, links) is dropped.
65 */
66export function ansiSegments(line: string): Segment[] {
67 const segments: Segment[] = [];
68 let colour = "";
69 let bold = false;
70 // SGR sequences are kept apart; anything else that starts with ESC goes.
71 const pattern = /\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g;
72 let at = 0;
73 const push = (text: string) => {
74 if (text) segments.push({ text, className: [colour, bold ? "font-semibold" : ""].filter(Boolean).join(" ") });
75 };
76 for (const match of line.matchAll(pattern)) {
77 push(line.slice(at, match.index));
78 at = match.index + match[0].length;
79 if (match[1] === undefined) continue;
80 const codes = match[1] === "" ? [0] : match[1].split(";").map(Number);
81 for (const code of codes) {
82 if (code === 0) {
83 colour = "";
84 bold = false;
85 } else if (code === 1) bold = true;
86 else if (code === 22) bold = false;
87 else if (code === 39) colour = "";
88 else if (ANSI_COLOURS[code]) colour = ANSI_COLOURS[code];
89 }
90 }
91 push(line.slice(at));
92 return segments;
93}
94
95/** Text without any ANSI escape sequences, for copying. */
96export function stripAnsi(text: string): string {
97 return ansiSegments(text.replace(/\r\n/g, "\n"))
98 .map((segment) => segment.text)
99 .join("");
100}
101
102/** Lines as a terminal would show them: a carriage return starts the line over. */
103function terminalLines(text: string): string[] {
104 return text
105 .replace(/\r\n/g, "\n")
106 .split("\n")
107 .map((line) => {
108 const parts = line.split("\r");
109 return parts[parts.length - 1] || parts.filter(Boolean).pop() || "";
110 });
111}
112
113/**
114 * A command's output as a log: numbered lines, colours rendered, a copy
115 * button, and a note when only the end of it was kept.
116 */
117export function LogViewer({ text }: { text: string }) {
118 const cut = text.startsWith(CUT_SHORT);
119 const body = cut ? text.slice(CUT_SHORT.length).replace(/^\n/, "") : text;
120 const lines = useMemo(() => {
121 const all = terminalLines(body);
122 while (all.length > 0 && all[all.length - 1].trim() === "") all.pop();
123 return all;
124 }, [body]);
125 const [copied, setCopied] = useState(false);
126 if (lines.length === 0) {
127 return <p className="px-3 py-2 font-mono text-xs text-faint">No output.</p>;
128 }
129 return (
130 <div className="relative">
131 <button
132 type="button"
133 aria-label="Copy the output"
134 title="Copy the output"
135 className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg"
136 onClick={() => {
137 void navigator.clipboard.writeText(stripAnsi(body));
138 setCopied(true);
139 setTimeout(() => setCopied(false), 1500);
140 }}
141 >
142 {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />}
143 </button>
144 {cut && (
145 <p className="border-b border-line px-3 py-1.5 text-xs text-faint">
146 Showing the end of the output. Earlier lines were not kept.
147 </p>
148 )}
149 <div className="max-h-96 overflow-auto py-1.5 font-mono text-xs leading-5">
150 {lines.map((line, index) => (
151 <div key={index} className="flex gap-3 pr-12 pl-2 hover:bg-raised/40">
152 <span className="w-9 shrink-0 text-right text-faint/70 select-none">{index + 1}</span>
153 <span className="min-w-0 break-all whitespace-pre-wrap text-fg/85">
154 {ansiSegments(line).map((segment, at) =>
155 segment.className ? (
156 <span key={at} className={segment.className}>
157 {segment.text}
158 </span>
159 ) : (
160 segment.text
161 ),
162 )}
163 </span>
164 </div>
165 ))}
166 </div>
167 </div>
168 );
169}
170
171// --- Checks ---------------------------------------------------------------
172
173function seconds(ms: number): string {
174 if (ms < 1000) return `${ms}ms`;
175 const s = Math.round(ms / 1000);
176 return s < 60 ? `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
177}
178
179type Standing = "passed" | "failed" | "running" | "queued" | "skipped";
180
181function StandingIcon({ standing, size = 15 }: { standing: Standing; size?: number }) {
182 if (standing === "passed") return <CircleCheck size={size} className="shrink-0 text-accent" aria-label="Passed" />;
183 if (standing === "failed") return <CircleX size={size} className="shrink-0 text-danger" aria-label="Failed" />;
184 if (standing === "running")
185 return <LoaderCircle size={size} className="shrink-0 animate-spin text-warn" aria-label="Running" />;
186 if (standing === "skipped") return <CircleAlert size={size} className="shrink-0 text-faint" aria-label="Skipped" />;
187 return <LoaderCircle size={size} className="shrink-0 text-faint" aria-label="Queued" />;
188}
189
190/** The id of the workflow run a status links to, if it links to one. */
191export function runIdOf(status: CommitStatus): string | null {
192 return /\/actions\/runs\/([^/?#]+)/.exec(status.targetUrl ?? "")?.[1] ?? null;
193}
194
195/** Where a job stands, in the box's terms. */
196function jobStanding(job: Job): Standing {
197 if (job.status !== "completed") return job.status === "in_progress" || job.status === "calling" ? "running" : "queued";
198 if (job.conclusion === "success") return "passed";
199 if (job.conclusion === "skipped" || job.conclusion === "cancelled") return "skipped";
200 return "failed";
201}
202
203function statusStanding(status: CommitStatus): Standing {
204 if (status.state === "pending") return "running";
205 return status.state === "success" ? "passed" : "failed";
206}
207
208/** One row of the checks list: an icon, a name, how long, and a way in. */
209function CheckLine({
210 standing,
211 name,
212 detail,
213 time,
214 timing = false,
215 to,
216}: {
217 standing: Standing;
218 name: React.ReactNode;
219 detail?: React.ReactNode;
220 time?: string;
221 /** Its jobs and their times are still being read. */
222 timing?: boolean;
223 to?: string;
224}) {
225 return (
226 <div className="flex items-center gap-2.5 px-4 py-2 text-sm">
227 <StandingIcon standing={standing} />
228 <span className="min-w-0 grow truncate">
229 {name}
230 {detail && <span className="text-muted"> — {detail}</span>}
231 </span>
232 {time && <span className="shrink-0 animate-fade-in font-mono text-xs text-faint">{time}</span>}
233 {timing && <SkeletonLine className="w-10 shrink-0 text-xs" />}
234 {to && (
235 <Link to={to} className="shrink-0 text-xs text-muted hover:text-fg hover:underline">
236 Details
237 </Link>
238 )}
239 </div>
240 );
241}
242
243/** A command from a run recorded before checks were workflows: what it printed, open when it failed. */
244function CommandResult({ result }: { result: CheckResult }) {
245 const timedOut = result.exitCode == null && !result.passed;
246 return (
247 <details className="group" open={!result.passed}>
248 <summary className="flex cursor-pointer list-none items-center gap-2.5 px-4 py-2 text-sm hover:bg-raised/40">
249 <StandingIcon standing={result.passed ? "passed" : "failed"} />
250 <span className="min-w-0 grow truncate">
251 <code className="font-mono text-xs">{result.command}</code>
252 {!result.passed && (
253 <span className="text-muted">
254 {" — "}
255 {timedOut ? "stopped for taking too long" : `exited with code ${result.exitCode}`}
256 </span>
257 )}
258 </span>
259 <span className="shrink-0 font-mono text-xs text-faint">{seconds(result.durationMs)}</span>
260 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
261 </summary>
262 <div className="mx-4 mb-3 overflow-hidden rounded-lg border border-line bg-bg">
263 <LogViewer text={result.output} />
264 </div>
265 </details>
266 );
267}
268
269/** Where a required check stands, in the box's terms. */
270function requiredStanding(check: RequiredCheck): Standing {
271 if (check.state === "success") return "passed";
272 if (check.state === "failure") return "failed";
273 return check.state === "pending" ? "running" : "queued";
274}
275
276/** How the required checks stand together: "2 of 3 passing", and what holds the rest. */
277export function requiredSummary(required: RequiredCheck[]): { title: string; sub: string; standing: Standing } {
278 const count = (state: RequiredCheck["state"]) => required.filter((check) => check.state === state).length;
279 const passing = count("success");
280 const failing = count("failure");
281 const running = count("pending");
282 const expected = count("expected");
283 const sub = [
284 failing > 0 && `${failing} failing`,
285 running > 0 && `${running} running`,
286 expected > 0 && `${expected} not reported yet`,
287 ]
288 .filter(Boolean)
289 .join(", ");
290 return {
291 title: `Required checks: ${passing} of ${required.length} passing`,
292 sub,
293 standing: failing > 0 ? "failed" : running + expected > 0 ? "running" : "passed",
294 };
295}
296
297/** The repository-relative address of a status's link, when it is on g1t. */
298function onSite(url: string | null): string | undefined {
299 if (!url) return undefined;
300 const path = url.replace(/^https:\/\/g1t\.sh(?=\/)/, "");
301 return path.startsWith("/") ? path : undefined;
302}
303
304/**
305 * Every check on a pull request, at a glance and in detail: first the
306 * checks the default branch requires, each as it stands on the head (one
307 * nothing has reported is waited for), then every workflow run on the
308 * head, job by job. A record of the merge queue taking it out comes last.
309 */
310export function ChecksSection({
311 run,
312 required,
313 statuses,
314 jobs,
315 pull,
316 base,
317 earlier,
318 canRerunWorkflows,
319 settingsUrl,
320 error,
321 loading = false,
322}: {
323 /** Set when the merge queue took it out, or for a run from before checks were workflows. */
324 run: CheckRun | null;
325 required: RequiredCheck[];
326 statuses: CommitStatus[];
327 /** Each workflow run's jobs, by run id, where they could be read. */
328 jobs: Record<string, Job[]>;
329 pull: Pull;
330 /** The repository's path, `/<owner>/<repo>`. */
331 base: string;
332 earlier: CheckRun[];
333 canRerunWorkflows: boolean;
334 /** Where the required checks are chosen, for those who may. */
335 settingsUrl?: string | null;
336 error?: string | null;
337 /** The workflow runs' jobs are still being read: their rows wait, busy. */
338 loading?: boolean;
339}) {
340 if (!run && statuses.length === 0 && required.length === 0) return null;
341
342 // Every workflow row, so they can be counted the same way they are shown.
343 const workflow: Standing[] = statuses.flatMap((status) => {
344 const id = runIdOf(status);
345 const theirs = id ? jobs[id] : undefined;
346 return theirs && theirs.length > 0 ? theirs.map(jobStanding) : [statusStanding(status)];
347 });
348 const failed = workflow.filter((standing) => standing === "failed").length;
349 const pending = workflow.filter((standing) => standing === "running" || standing === "queued").length;
350 const passed = workflow.filter((standing) => standing === "passed").length;
351 const skipped = workflow.filter((standing) => standing === "skipped").length;
352 const total = workflow.length;
353 const queueFailed = run?.status === "failed" && run.results.length === 0;
354
355 const summary = required.length > 0 ? requiredSummary(required) : null;
356 const headline = summary
357 ? summary.title
358 : failed > 0
359 ? `${failed} of ${total} ${total === 1 ? "check" : "checks"} failed`
360 : pending > 0
361 ? `${pending} of ${total} ${total === 1 ? "check is" : "checks are"} still running`
362 : total > 0
363 ? "All checks have passed"
364 : "Checks";
365 const others = [
366 failed > 0 && `${failed} failed`,
367 pending > 0 && `${pending} running`,
368 passed > 0 && `${passed} passed`,
369 skipped > 0 && `${skipped} skipped or cancelled`,
370 ]
371 .filter(Boolean)
372 .join(", ");
373 const sub = summary
374 ? [summary.sub, others && `all checks: ${others}`].filter(Boolean).join(" · ")
375 : [others, "none are required, so none hold the merge"].filter(Boolean).join(" · ");
376 const standing: Standing = summary
377 ? summary.standing
378 : failed > 0
379 ? "failed"
380 : pending > 0
381 ? "running"
382 : "passed";
383
384 const commitPath = (sha: string) =>
385 pull.fork ? `/${pull.fork.namespace}/${pull.fork.name}/commit/${sha}` : `${base}/commit/${sha}`;
386 const older = run != null && pull.headCommit != null && run.headCommit !== pull.headCommit;
387 const failedRuns = statuses.filter((status) => statusStanding(status) === "failed" && runIdOf(status));
388
389 return (
390 <div aria-busy={loading || undefined}>
391 <div className="flex items-start gap-3 px-4 py-3">
392 <span className="mt-0.5 shrink-0">
393 {standing === "failed" || queueFailed ? (
394 <CircleX size={16} className="text-danger" />
395 ) : standing === "running" ? (
396 <LoaderCircle size={16} className="animate-spin text-warn" />
397 ) : (
398 <CircleCheck size={16} className="text-accent" />
399 )}
400 </span>
401 <div className="min-w-0 grow">
402 <p className="text-sm font-medium">{headline}</p>
403 {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
404 {!summary && settingsUrl && (
405 <p className="mt-0.5 text-xs text-muted">
406 <Link to={settingsUrl} className="text-fg hover:underline">
407 Choose required checks
408 </Link>{" "}
409 to hold merges until they pass.
410 </p>
411 )}
412 </div>
413 <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
414 {canRerunWorkflows &&
415 failedRuns.map((status) => (
416 <Form method="post" key={status.context}>
417 <input type="hidden" name="action" value="rerun-workflow" />
418 <input type="hidden" name="run" value={runIdOf(status) ?? ""} />
419 <SubmitButton
420 variant="quiet"
421 match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }}
422 pending="Re-running…"
423 title={`Re-run the failed jobs of ${status.context}`}
424 >
425 <RotateCw size={13} />
426 Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""}
427 </SubmitButton>
428 </Form>
429 ))}
430 </div>
431 </div>
432 {error && (
433 <div className="px-4 pb-2">
434 <ErrorText>{error}</ErrorText>
435 </div>
436 )}
437
438 <div className="divide-y divide-line border-t border-line">
439 {required.length > 0 && (
440 <section aria-label="Required checks" className="py-1">
441 <p className="px-4 pt-1.5 pb-1 text-xs font-medium text-muted">Required</p>
442 {required.map((check) => (
443 <CheckLine
444 key={check.name}
445 standing={requiredStanding(check)}
446 name={
447 <>
448 {check.name}
449 <span className="ml-1.5 rounded-full border border-line px-1.5 py-px text-[0.625rem] text-faint">Required</span>
450 </>
451 }
452 detail={
453 check.state === "expected"
454 ? "Waiting for status to be reported"
455 : (check.description ?? (check.state === "pending" ? "running" : undefined))
456 }
457 to={onSite(check.targetUrl)}
458 />
459 ))}
460 </section>
461 )}
462
463 {statuses.length > 0 && (
464 <section aria-label="All checks" className="py-1">
465 <p className="px-4 pt-1.5 pb-1 text-xs font-medium text-muted">{required.length > 0 ? "All checks" : "Workflows"}</p>
466 {statuses.map((status) => {
467 const id = runIdOf(status);
468 const runPath = id ? `${base}/actions/runs/${id}` : onSite(status.targetUrl);
469 const theirs = id ? jobs[id] : undefined;
470 if (!theirs || theirs.length === 0) {
471 return (
472 <CheckLine
473 key={status.context}
474 standing={statusStanding(status)}
475 name={status.context}
476 detail={status.description ?? undefined}
477 timing={loading && id != null}
478 to={runPath}
479 />
480 );
481 }
482 return theirs.map((job) => (
483 <CheckLine
484 key={`${status.context}-${job.id}`}
485 standing={jobStanding(job)}
486 name={
487 <>
488 <span className="text-muted">{status.context} / </span>
489 {job.name}
490 </>
491 }
492 detail={job.reason ?? (jobStanding(job) === "failed" ? "failed" : undefined)}
493 time={duration(job.startedAt, job.finishedAt) || undefined}
494 to={`${runPath}?job=${job.id}`}
495 />
496 ));
497 })}
498 </section>
499 )}
500
501 {run && (
502 <section aria-label={queueFailed ? "Merge queue" : "Earlier commands"} className="py-1">
503 <p className="flex flex-wrap items-center gap-x-1.5 px-4 pt-1.5 pb-1 text-xs text-faint">
504 <span className="font-medium text-muted">{queueFailed ? "Merge queue" : "Commands from the issue"}</span>
505 <span>· on</span>
506 <Link to={commitPath(run.headCommit)} className="inline-flex items-center gap-1 font-mono hover:text-fg">
507 <GitCommitHorizontal size={12} />
508 {run.headCommit.slice(0, 7)}
509 </Link>
510 {run.finishedAt ? (
511 <span>
512 · <TimeAgo at={run.finishedAt} />
513 </span>
514 ) : (
515 <span>
516 · started <TimeAgo at={run.createdAt} />
517 </span>
518 )}
519 </p>
520 {older && !queueFailed && (
521 <p className="mx-4 my-1.5 flex items-start gap-2 rounded-md bg-warn/10 px-2.5 py-1.5 text-xs text-warn">
522 <TriangleAlert size={13} className="mt-0.5 shrink-0" />
523 These ran on an older commit, before the latest push,{" "}
524 <span className="font-mono">{pull.headCommit?.slice(0, 7)}</span>.
525 </p>
526 )}
527 {run.error && (
528 <p className="mx-4 my-1.5 rounded-lg border border-line bg-bg px-3 py-2 text-sm text-muted">{run.error}</p>
529 )}
530 {run.results.map((result, index) => (
531 <CommandResult key={`${result.command}-${index}`} result={result} />
532 ))}
533 </section>
534 )}
535
536 {earlier.length > 0 && (
537 <details className="group">
538 <summary className="flex cursor-pointer list-none items-center gap-2 px-4 py-2 text-xs text-muted hover:text-fg">
539 <History size={13} />
540 Earlier records ({earlier.length})
541 <ChevronRight size={13} className="transition-transform group-open:rotate-90" />
542 </summary>
543 <ul className="space-y-1 px-4 pb-3 text-xs">
544 {earlier.map((before) => (
545 <li key={before.id} className="flex items-center gap-2 text-muted">
546 <StandingIcon
547 size={13}
548 standing={
549 before.status === "passed"
550 ? "passed"
551 : before.status === "queued" || before.status === "running"
552 ? "queued"
553 : "failed"
554 }
555 />
556 <Link to={commitPath(before.headCommit)} className="font-mono hover:text-fg">
557 {before.headCommit.slice(0, 7)}
558 </Link>
559 <span className="min-w-0 truncate">
560 {before.status === "errored"
561 ? `could not run${before.error ? `: ${before.error}` : ""}`
562 : before.results.length > 0
563 ? `${before.results.filter((result) => result.passed).length} of ${before.results.length} passed`
564 : (before.error ?? before.status)}
565 </span>
566 <span className="ml-auto shrink-0 text-faint">
567 <TimeAgo at={before.createdAt} />
568 </span>
569 </li>
570 ))}
571 </ul>
572 </details>
573 )}
574 </div>
575 </div>
576 );
577}
578
579// --- Mergeability ---------------------------------------------------------
580
581/** How to resolve the conflicts by hand, step by step, for a branch or a fork. */
582export function commandLineSteps(
583 pull: Pull,
584 owner: string,
585 repo: string,
586 defaultBranch: string,
587 files: string[],
588 site = "https://g1t.sh",
589): string[] {
590 const upstream = `${site}/${owner}/${repo}.git`;
591 const add = files.length > 0 && files.length <= 6 ? `git add ${files.join(" ")}` : "git add -A";
592 if (pull.branch) {
593 return [
594 `git fetch origin`,
595 `git checkout ${pull.branch}`,
596 `git merge origin/${defaultBranch}`,
597 `${add} && git commit --no-edit`,
598 `git push origin ${pull.branch}`,
599 ];
600 }
601 const fork = pull.fork!;
602 return [
603 `git clone ${site}/${fork.namespace}/${fork.name}.git && cd ${fork.name}`,
604 `git pull --no-rebase ${upstream} ${defaultBranch}`,
605 `${add} && git commit --no-edit`,
606 `git push`,
607 ];
608}
609
610/**
611 * The branch conflicts with its target: which files, and three ways out —
612 * g1t, the browser (soon), or the command line.
613 */
614export function ConflictsSection({
615 conflicts,
616 pull,
617 owner,
618 repo,
619 defaultBranch,
620 changesUrl,
621 canResolve,
622 resolving,
623 error,
624}: {
625 conflicts: string[];
626 pull: Pull;
627 owner: string;
628 repo: string;
629 defaultBranch: string;
630 /** Where the pull request's changes are shown, to link each file. */
631 changesUrl: string;
632 /** Whether the viewer may have g1t resolve them. */
633 canResolve: boolean;
634 /** Whether asking for it is on its way. */
635 resolving: boolean;
636 error?: string | null;
637}) {
638 const { site } = useAddresses();
639 const steps = commandLineSteps(pull, owner, repo, defaultBranch, conflicts, site);
640 return (
641 <div className="bg-danger/5">
642 <div className="flex gap-3 px-4 py-3">
643 <CircleAlert size={16} className="mt-0.5 shrink-0 text-danger" />
644 <div className="min-w-0 grow">
645 <p className="text-sm font-medium">This branch has conflicts that must be resolved</p>
646 <p className="mt-0.5 text-sm text-muted">
647 Merging <span className="font-mono">{defaultBranch}</span> into it stops on{" "}
648 {conflicts.length === 1 ? "one file" : `${conflicts.length} files`}. It cannot merge until they are resolved.
649 </p>
650 {conflicts.length > 0 && (
651 <>
652 <p className="mt-3 text-xs font-medium text-muted">Conflicting files</p>
653 <ul className="mt-1.5 space-y-1">
654 {conflicts.map((path) => (
655 <li key={path}>
656 <Link
657 to={`${changesUrl}#file-${path}`}
658 className="font-mono text-xs text-fg hover:text-danger hover:underline"
659 >
660 {path}
661 </Link>
662 </li>
663 ))}
664 </ul>
665 </>
666 )}
667 <div className="mt-3 flex flex-wrap items-center gap-2">
668 {canResolve && (
669 <Form method="post">
670 <input type="hidden" name="action" value="update" />
671 <Button variant="primary" type="submit" disabled={resolving}>
672 {resolving ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
673 {resolving ? "Starting g1t…" : "Resolve with g1t"}
674 </Button>
675 </Form>
676 )}
677 <Tooltip>
678 <TooltipTrigger asChild>
679 {/* A disabled button gets no pointer events; the span carries the tooltip. */}
680 <span tabIndex={0} className="inline-flex">
681 <Button variant="quiet" type="button" disabled aria-disabled="true">
682 Resolve in the browser
683 <span className="rounded-full border border-line px-1.5 text-[0.625rem] text-faint">Soon</span>
684 </Button>
685 </span>
686 </TooltipTrigger>
687 <TooltipContent>
688 An editor for conflicts in the browser is coming. Until then, have g1t resolve them, or use the
689 command line.
690 </TooltipContent>
691 </Tooltip>
692 </div>
693 {error && (
694 <div className="mt-2">
695 <ErrorText>{error}</ErrorText>
696 </div>
697 )}
698 <details className="group mt-3">
699 <summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-muted hover:text-fg">
700 <Terminal size={13} />
701 Resolve on the command line
702 <ChevronRight size={13} className="transition-transform group-open:rotate-90" />
703 </summary>
704 <ol className="mt-2 space-y-2 text-xs text-muted">
705 {steps.map((step, index) => (
706 <li key={step}>
707 <p className="mb-1">
708 {index + 1}.{" "}
709 {step.startsWith("git add")
710 ? "Fix the conflicts in each file, removing the conflict markers, then commit the merge:"
711 : step.startsWith("git push")
712 ? "Push it. The pull request updates, and its checks run again:"
713 : step.includes("merge") || step.includes("pull")
714 ? `Merge ${defaultBranch} in. Git stops on the conflicting files:`
715 : step.startsWith("git clone")
716 ? "Clone the pull request's fork:"
717 : step.startsWith("git checkout")
718 ? "Check out the pull request's branch:"
719 : "Get the latest of both:"}
720 </p>
721 <CopyLine text={step} prompt />
722 </li>
723 ))}
724 </ol>
725 {pull.fork && (
726 <p className="mt-2 text-xs text-faint">
727 A pull request's fork takes pushes from whoever opened it.
728 </p>
729 )}
730 </details>
731 </div>
732 </div>
733 </div>
734 );
735}
736
737/** Whether the change merges cleanly, when that is not a conflict. */
738export function MergeabilityRow({
739 mergeable,
740 defaultBranch,
741}: {
742 mergeable: Mergeable;
743 defaultBranch: string;
744}) {
745 if (mergeable === "checking") {
746 return (
747 <div className="flex gap-3 px-4 py-3 text-sm">
748 <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-faint" />
749 <div>
750 <p className="font-medium">Checking whether this merges cleanly…</p>
751 <p className="mt-0.5 text-muted">
752 It and <span className="font-mono">{defaultBranch}</span> changed some of the same files, so g1t is merging
753 the two in a sandbox to see.
754 </p>
755 </div>
756 </div>
757 );
758 }
759 if (mergeable === "clean") {
760 return (
761 <div className="flex gap-3 px-4 py-3 text-sm">
762 <CircleCheck size={16} className="mt-0.5 shrink-0 text-accent" />
763 <p className="font-medium">
764 This branch has no conflicts with <span className="font-mono">{defaultBranch}</span>
765 </p>
766 </div>
767 );
768 }
769 return null;
770}
771
772/**
773 * A catch-up handed to a sandbox, while it lasts: who is doing what, the
774 * run's live step, and how long it has taken. It ends with the pull request
775 * up to date (and this disappears), or with a plain failure and a way to
776 * try again. It never spins on with no end.
777 */
778export function CatchUpProgress({
779 owner,
780 repo,
781 number,
782 defaultBranch,
783 behind,
784 update,
785 startedAt,
786 retrying,
787 runs: initial,
788}: {
789 owner: string;
790 repo: string;
791 number: number;
792 defaultBranch: string;
793 behind: boolean;
794 update: Extract<PullBranchUpdate, { outcome: "needs_agent" }>;
795 /** When it was asked for, in ms since the epoch. */
796 startedAt: number;
797 /** Whether a retry is on its way. */
798 retrying: boolean;
799 /** The pull request's latest runs, from the page's loader. */
800 runs?: Live | null;
801}) {
802 // The page revalidates while this is working, which reloads the runs too.
803 const data = useRuns(owner, repo, { number: String(number), limit: "5" }, initial);
804 const [now, setNow] = useState(() => Date.now());
805 useEffect(() => {
806 const timer = setInterval(() => setNow(Date.now()), 5000);
807 return () => clearInterval(timer);
808 }, []);
809 const run = catchUpRun(data?.runs ?? [], startedAt);
810 const phase = catchUpPhase({ behind, run, startedAt, now });
811 if (phase === "done") return null;
812 const session = `/${owner}/${repo}/sessions/${number}`;
813 if (phase !== "working") {
814 return (
815 <div className="flex gap-3 px-4 py-3 text-sm">
816 <TriangleAlert size={16} className={`mt-0.5 shrink-0 ${phase === "failed" ? "text-danger" : "text-warn"}`} />
817 <div className="min-w-0">
818 <p className="font-medium">
819 {phase === "failed"
820 ? `Catching up with ${defaultBranch} failed`
821 : `Catching up with ${defaultBranch} is taking longer than it should`}
822 </p>
823 <p className="mt-0.5 text-muted">
824 {phase === "failed"
825 ? `${run?.error ? `${run.error} ` : ""}Nothing was pushed, so the pull request is as it was.`
826 : "It usually takes about a minute. It may still finish; this page updates if it does."}{" "}
827 <Link to={session} className="text-fg hover:underline">
828 See the session
829 </Link>
830 .
831 </p>
832 <Form method="post" className="mt-2">
833 <input type="hidden" name="action" value="update" />
834 <Button variant="quiet" type="submit" disabled={retrying}>
835 {retrying ? <LoaderCircle size={14} className="animate-spin" /> : <RotateCw size={14} />}
836 {retrying ? "Trying again…" : "Try again"}
837 </Button>
838 </Form>
839 </div>
840 </div>
841 );
842 }
843 return (
844 <div className="flex gap-3 px-4 py-3 text-sm">
845 <LoaderCircle size={16} className="mt-0.5 shrink-0 animate-spin text-merged" />
846 <div className="min-w-0 grow">
847 <p className="font-medium">{catchUpTitle(update.reason, defaultBranch)}</p>
848 <p className="mt-0.5 text-muted">
849 {catchUpWhy(update, defaultBranch)} This usually takes about a minute; the pull request updates here when it
850 is pushed.
851 </p>
852 {update.paths.length > 0 && (
853 <p className="mt-1 truncate font-mono text-xs text-faint" title={update.paths.join(", ")}>
854 {update.paths.slice(0, 5).join(", ")}
855 {update.paths.length > 5 && ` and ${update.paths.length - 5} more`}
856 </p>
857 )}
858 <p
859 className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line"
860 title={run?.step ?? undefined}
861 >
862 <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
863 {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")}
864 </p>
865 <p className="mt-2 flex flex-wrap items-center gap-x-3 text-xs text-muted">
866 <Elapsed from={new Date(startedAt).toISOString()} />
867 <Link to={session} className="hover:text-fg">
868 Watch the session
869 </Link>
870 </p>
871 </div>
872 </div>
873 );
874}