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

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