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

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