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