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

848 lines33,096 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1/**
2 * The parts of a pull request's merge box that say what stands between it
Fast pages, required checks on the branch, self-hosted runners, honest incidents3 * and a merge: the checks the branch requires and every other check, job by
4 * job, and whether
Agents and memory, checks and conflicts, profiles, slug renames, custom domains5 * 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";
Catching up with main takes seconds when the two sides touched different files22import { useEffect, useMemo, useState } from "react";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains23import { Form, Link } from "react-router";
24
Fast pages, required checks on the branch, self-hosted runners, honest incidents25import type { CheckResult, CheckRun, CommitStatus, Job, Mergeable, PullBranchUpdate, Pull, RequiredCheck } from "@g1t/contracts";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains26
Catching up with main takes seconds when the two sides touched different files27import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains28import { duration } from "./actions";
Catching up with main takes seconds when the two sides touched different files29import { Elapsed, useRuns } from "./agents";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains30import { 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents237/** A command from a run recorded before checks were workflows: what it printed, open when it failed. */
238function CommandResult({ result }: { result: CheckResult }) {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains239 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents263/** 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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains298/**
Fast pages, required checks on the branch, self-hosted runners, honest incidents299 * 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.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains303 */
304export function ChecksSection({
305 run,
Fast pages, required checks on the branch, self-hosted runners, honest incidents306 required,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains307 statuses,
308 jobs,
309 pull,
310 base,
311 earlier,
312 canRerunWorkflows,
Fast pages, required checks on the branch, self-hosted runners, honest incidents313 settingsUrl,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains314 error,
315}: {
Fast pages, required checks on the branch, self-hosted runners, honest incidents316 /** Set when the merge queue took it out, or for a run from before checks were workflows. */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains317 run: CheckRun | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents318 required: RequiredCheck[];
Agents and memory, checks and conflicts, profiles, slug renames, custom domains319 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;
Fast pages, required checks on the branch, self-hosted runners, honest incidents327 /** Where the required checks are chosen, for those who may. */
328 settingsUrl?: string | null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains329 error?: string | null;
330}) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents331 if (!run && statuses.length === 0 && required.length === 0) return null;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains332
Fast pages, required checks on the branch, self-hosted runners, honest incidents333 // Every workflow row, so they can be counted the same way they are shown.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains334 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 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents339 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;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains345
Fast pages, required checks on the branch, self-hosted runners, honest incidents346 const summary = required.length > 0 ? requiredSummary(required) : null;
347 const headline = summary
348 ? summary.title
349 : failed > 0
Agents and memory, checks and conflicts, profiles, slug renames, custom domains350 ? `${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";
Fast pages, required checks on the branch, self-hosted runners, honest incidents356 const others = [
357 failed > 0 && `${failed} failed`,
358 pending > 0 && `${pending} running`,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains359 passed > 0 && `${passed} passed`,
Fast pages, required checks on the branch, self-hosted runners, honest incidents360 skipped > 0 && `${skipped} skipped or cancelled`,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains361 ]
362 .filter(Boolean)
363 .join(", ");
Fast pages, required checks on the branch, self-hosted runners, honest incidents364 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";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains374
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">
Fast pages, required checks on the branch, self-hosted runners, honest incidents384 {standing === "failed" || queueFailed ? (
Agents and memory, checks and conflicts, profiles, slug renames, custom domains385 <CircleX size={16} className="text-danger" />
Fast pages, required checks on the branch, self-hosted runners, honest incidents386 ) : standing === "running" ? (
Agents and memory, checks and conflicts, profiles, slug renames, custom domains387 <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>}
Fast pages, required checks on the branch, self-hosted runners, honest incidents395 {!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 )}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains403 </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">
Fast pages, required checks on the branch, self-hosted runners, honest incidents425 {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 ))}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains446 </section>
447 )}
448
449 {statuses.length > 0 && (
Fast pages, required checks on the branch, self-hosted runners, honest incidents450 <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>
Agents and memory, checks and conflicts, profiles, slug renames, custom domains452 {statuses.map((status) => {
453 const id = runIdOf(status);
Fast pages, required checks on the branch, self-hosted runners, honest incidents454 const runPath = id ? `${base}/actions/runs/${id}` : onSite(status.targetUrl);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains455 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents486 {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
Agents and memory, checks and conflicts, profiles, slug renames, custom domains521 {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} />
Fast pages, required checks on the branch, self-hosted runners, honest incidents525 Earlier records ({earlier.length})
Agents and memory, checks and conflicts, profiles, slug renames, custom domains526 <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`
Fast pages, required checks on the branch, self-hosted runners, honest incidents549 : (before.error ?? before.status)}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains550 </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 * the g1t agent, 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 the g1t agent resolve them. */
611 canResolve: boolean;
Catching up with main takes seconds when the two sides touched different files612 /** Whether asking for it is on its way. */
Agents and memory, checks and conflicts, profiles, slug renames, custom domains613 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 && (
Catching up with main takes seconds when the two sides touched different files646 <Form method="post">
Agents and memory, checks and conflicts, profiles, slug renames, custom domains647 <input type="hidden" name="action" value="update" />
648 <Button variant="primary" type="submit" disabled={resolving}>
Catching up with main takes seconds when the two sides touched different files649 {resolving ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
650 {resolving ? "Starting g1t-agent…" : "Resolve with g1t agent"}
Agents and memory, checks and conflicts, profiles, slug renames, custom domains651 </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 the g1t agent 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}
Catching up with main takes seconds when the two sides touched different files748
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}