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

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