g1t/apps/web/app/routes/repo/pull.tsx

528 lines19,654 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.

Acceptance checks in sandboxes, line comments and review verdicts1import { env } from "cloudflare:workers";
Issues and pull requests replace intents and attempts2import {
3 Bot,
4 ChevronRight,
5 FileDiff,
Pull requests from branches6 GitBranch,
Issues and pull requests replace intents and attempts7 GitCommitHorizontal,
Acceptance checks in sandboxes, line comments and review verdicts8 CircleCheck,
9 CircleSlash,
Issues and pull requests replace intents and attempts10 GitMerge,
11 MessageSquare,
12 MessagesSquare,
13 StickyNote,
14 User,
15 Wrench,
16} from "lucide-react";
17import { useEffect } from "react";
18import { Form, Link, redirect, useRevalidator } from "react-router";
19
Pull requests from branches20import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
Issues and pull requests replace intents and attempts21
22import type { Route } from "./+types/pull";
23import { DiffView } from "../../components/diff-view";
24import { Markdown } from "../../components/markdown";
25import {
26 Avatar,
27 Button,
28 CopyLine,
29 EmptyState,
30 ErrorText,
31 Textarea,
32 TimeAgo,
33} from "../../components/ui";
Acceptance checks in sandboxes, line comments and review verdicts34import { ChecksPanel } from "../../components/checks";
35import { Comments, IssueIcon, PullState, verdicts } from "../../components/work";
Issues and pull requests replace intents and attempts36import { repos, work } from "../../lib/services.server";
37import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
38
39const REFRESH_MS = 4000;
40const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
41const TABS = ["conversation", "session", "changes"] as const;
42type Tab = (typeof TABS)[number];
43
44export function meta({ loaderData, params }: Route.MetaArgs) {
45 const title = loaderData
46 ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
47 : "";
48 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
49}
50
51export async function loader({ params, context, request }: Route.LoaderArgs) {
52 const viewer = getViewer(context);
53 const path = { namespace: params.owner, name: params.repo };
54 const number = Number(params.number);
55 const asked = new URL(request.url).searchParams.get("tab");
56 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
57
58 const [found, repo] = await Promise.all([
59 work.getPull(path, number, viewer),
60 repos.get(path, viewer),
61 ]);
62 if (!found.ok) {
63 // Issues and pull requests share numbers; this one may be an issue.
64 const issue = await work.getIssue(path, number, viewer);
65 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
66 throw new Response("Pull request not found.", { status: 404 });
67 }
68 const { pull } = found.value;
Pull requests from branches69 const range = pullComparison(pull);
Issues and pull requests replace intents and attempts70 const [session, comparison] = await Promise.all([
71 tab === "session" ? work.readSession(path, number, viewer) : null,
Pull requests from branches72 tab === "changes"
73 ? repos.compare(range.repoId, viewer, range.base, range.head)
74 : null,
Issues and pull requests replace intents and attempts75 ]);
76 const member = (viewer?.workspaces ?? []).some(
77 (membership) => membership.slug === params.owner,
78 );
79 return {
80 ...found.value,
81 tab,
82 session: session?.ok ? session.value : [],
83 // An empty comparison if it could not be made.
84 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
85 viewer,
86 // Members of the repository's workspace can merge.
87 canMerge: member,
88 canManage: member || viewer?.id === pull.author.id,
89 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
90 };
91}
92
93export async function action({ request, params, context }: Route.ActionArgs) {
94 assertSameOrigin(request);
95 const user = requireUser(context, request);
96 const form = await request.formData();
97 const path = { namespace: params.owner, name: params.repo };
98 const number = Number(params.number);
99 const action = form.get("action");
Acceptance checks in sandboxes, line comments and review verdicts100 const verdict = form.get("verdict");
101 const line = Number(form.get("line"));
Issues and pull requests replace intents and attempts102 const result =
103 action === "merge"
Acceptance checks in sandboxes, line comments and review verdicts104 ? await work.mergePull(user, path, number, {
105 keepIssueOpen: form.get("keepIssueOpen") === "on",
106 ignoreChecks: form.get("ignoreChecks") === "on",
107 })
Issues and pull requests replace intents and attempts108 : action === "close"
109 ? await work.closePull(user, path, number)
Acceptance checks in sandboxes, line comments and review verdicts110 : action === "recheck"
111 ? await env.RUNNER.recheck(user, path, number)
112 : action === "comment"
113 ? await work.addComment(user, path, number, {
114 body: String(form.get("body") ?? ""),
115 path: String(form.get("path") ?? "") || undefined,
116 line: line > 0 ? line : undefined,
117 verdict:
118 verdict === "approve" || verdict === "request_changes" ? verdict : undefined,
119 })
120 : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
Issues and pull requests replace intents and attempts121 return result.ok ? null : { error: result.error.message, action };
122}
123
124function TabLink({
125 to,
126 active,
127 children,
128}: {
129 to: string;
130 active: boolean;
131 children: React.ReactNode;
132}) {
133 return (
134 <Link
135 to={to}
136 preventScrollReset
137 className={
138 "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
139 (active
140 ? "border-accent font-medium text-fg"
141 : "border-transparent text-muted hover:text-fg")
142 }
143 >
144 {children}
145 </Link>
146 );
147}
148
149/** One step of the session, on the timeline's rail. */
150function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
151 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
152 const Icon =
153 entry.kind === "prompt"
154 ? User
155 : entry.kind === "note"
156 ? StickyNote
157 : isTool
158 ? Wrench
159 : Bot;
160 return (
161 <li className="relative pl-10">
162 <span
163 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
164 entry.kind === "prompt"
165 ? "border-accent/50 text-accent"
166 : "border-line text-faint"
167 }`}
168 >
169 <Icon size={14} />
170 </span>
171 {isTool ? (
172 <details className="group rounded-lg border border-line bg-surface">
173 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
174 <ChevronRight
175 size={14}
176 className="text-faint transition-transform group-open:rotate-90"
177 />
178 <span className="font-mono text-xs text-accent">
179 {entry.tool ?? "tool"}
180 </span>
181 <span className="truncate font-mono text-xs text-muted">
182 {entry.kind === "tool_result" ? "→ " : ""}
183 {entry.text.split("\n")[0]}
184 </span>
185 </summary>
186 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
187 {entry.text}
188 </pre>
189 </details>
190 ) : (
191 <div>
192 <p className="text-xs font-medium text-faint">
193 {entry.kind === "prompt"
194 ? "Prompt"
195 : entry.kind === "note"
196 ? "Note"
197 : agent}
198 </p>
199 {entry.kind === "message" ? (
200 <div className="mt-1">
201 <Markdown source={entry.text} />
202 </div>
203 ) : (
204 <p
205 className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
206 entry.kind === "prompt" ? "font-medium" : ""
207 }`}
208 >
209 {entry.text}
210 </p>
211 )}
212 </div>
213 )}
214 {entry.commit && (
215 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
216 <GitCommitHorizontal size={12} />
217 {entry.commit.slice(0, 7)}
218 </p>
219 )}
220 </li>
221 );
222}
223
224export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
225 const {
226 pull,
227 issue,
228 comments,
Acceptance checks in sandboxes, line comments and review verdicts229 checks,
Issues and pull requests replace intents and attempts230 tab,
231 session,
232 comparison,
233 viewer,
234 canMerge,
235 canManage,
236 defaultBranch,
237 } = loaderData;
238 const base = `/${params.owner}/${params.repo}`;
239 const here = `${base}/pull/${pull.number}`;
Pull requests from branches240 const remote = pull.fork
241 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
242 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
Issues and pull requests replace intents and attempts243 const active = pull.status === "draft" || pull.status === "open";
244
Acceptance checks in sandboxes, line comments and review verdicts245 // Follow an agent at work, or checks in progress, without a manual reload.
Issues and pull requests replace intents and attempts246 const revalidator = useRevalidator();
247 const working = pull.status === "draft";
Acceptance checks in sandboxes, line comments and review verdicts248 const checking = checks?.status === "queued" || checks?.status === "running";
249 const reviews = verdicts(comments);
250 // What stands between this pull request and a merge, if anything.
251 const unchecked = checks && checks.status !== "passed";
Issues and pull requests replace intents and attempts252 useEffect(() => {
Acceptance checks in sandboxes, line comments and review verdicts253 if (!working && !checking) return;
Issues and pull requests replace intents and attempts254 const timer = setInterval(() => {
255 if (document.visibilityState === "visible") revalidator.revalidate();
256 }, REFRESH_MS);
257 return () => clearInterval(timer);
Acceptance checks in sandboxes, line comments and review verdicts258 }, [working, checking, revalidator]);
Issues and pull requests replace intents and attempts259
260 return (
261 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
262 <div className="min-w-0">
263 <h2 className="text-2xl font-semibold tracking-tight text-balance">
264 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
265 </h2>
266 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
267 <PullState status={pull.status} />
268 <span className="flex items-center gap-2">
269 <Avatar name={pull.author.username} size={18} />
270 <span>
271 <span className="font-medium text-fg">{pull.author.username}</span>{" "}
Pull requests from branches272 {pull.status === "merged" ? "merged" : "wants to merge"}
273 {pull.branch && (
274 <>
275 {" "}
276 <span className="font-mono text-fg">{pull.branch}</span>
277 </>
278 )}{" "}
279 into <span className="font-mono text-fg">{defaultBranch}</span>
Issues and pull requests replace intents and attempts280 </span>
281 </span>
Pull requests from branches282 {/* A pull request from a branch was made by its author, not an agent. */}
283 {!pull.branch && (
284 <span className="flex items-center gap-1.5 font-mono text-xs">
285 <Bot size={14} />
286 {pull.agent}
287 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
288 </span>
289 )}
Issues and pull requests replace intents and attempts290 </div>
291
Acceptance checks in sandboxes, line comments and review verdicts292 {reviews.length > 0 && (
293 <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
294 {reviews.map(({ reviewer, verdict }) => (
295 <span
296 key={reviewer}
297 className={`flex items-center gap-1.5 ${
298 verdict === "approve" ? "text-accent" : "text-danger"
299 }`}
300 >
301 {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />}
302 {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "}
303 <span className="font-medium">{reviewer}</span>
304 </span>
305 ))}
306 </p>
307 )}
308
Issues and pull requests replace intents and attempts309 {issue && (
310 <Link
311 to={`${base}/issues/${issue.number}`}
312 className="mt-4 flex items-center gap-2.5 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm transition-colors hover:border-line-strong"
313 >
314 <IssueIcon issue={issue} />
315 <span className="text-muted">
316 {pull.status === "merged" && issue.resolvedBy === pull.number
317 ? "Resolved"
318 : "For issue"}
319 </span>
320 <span className="truncate font-medium">{issue.title}</span>
321 <span className="text-faint">#{issue.number}</span>
322 {issue.pullCount > 1 && (
323 <span className="ml-auto shrink-0 text-xs text-faint">
324 1 of {issue.pullCount} pull requests
325 </span>
326 )}
327 </Link>
328 )}
329
330 {pull.status === "merged" && (
331 <p className="mt-4 flex items-center gap-2.5 rounded-xl border border-merged/40 bg-merged/5 px-4 py-2.5 text-sm">
332 <GitMerge size={16} className="shrink-0 text-merged" />
333 <span>
334 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
335 <span className="font-medium">{pull.mergedBy}</span>{" "}
336 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
337 {pull.headCommit && (
338 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
339 )}
340 </span>
341 </p>
342 )}
343 {pull.supersededBy != null && (
344 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
345 Closed because{" "}
346 <Link
347 to={`${base}/pull/${pull.supersededBy}`}
348 className="font-medium text-fg hover:underline"
349 >
350 #{pull.supersededBy}
351 </Link>{" "}
352 was merged for this issue instead.
353 </p>
354 )}
355
356 <nav className="mt-8 flex gap-6 border-b border-line">
357 <TabLink to={here} active={tab === "conversation"}>
358 <MessageSquare size={15} />
359 Conversation
360 </TabLink>
361 <TabLink to={here + "?tab=session"} active={tab === "session"}>
362 <MessagesSquare size={15} />
363 Session
364 </TabLink>
365 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
366 <FileDiff size={15} />
367 Changes
368 </TabLink>
369 </nav>
370 <div className="mt-5">
371 {comparison ? (
Acceptance checks in sandboxes, line comments and review verdicts372 <DiffView
373 comparison={comparison}
374 review={{
375 comments: comments.filter((comment) => comment.path),
376 canComment: Boolean(viewer),
377 }}
378 />
Issues and pull requests replace intents and attempts379 ) : tab === "session" ? (
380 session.length === 0 ? (
381 <EmptyState title="Nothing recorded yet">
382 The agent's prompts, reasoning and tool calls appear here as it
383 works.
384 </EmptyState>
385 ) : (
386 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
387 {session.map((entry) => (
388 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
389 ))}
390 </ol>
391 )
392 ) : (
393 <div className="space-y-4">
394 {pull.body ? (
395 <section className="rounded-xl border border-line bg-surface p-5">
396 <Markdown source={pull.body} />
397 </section>
398 ) : (
399 <p className="rounded-xl border border-dashed border-line px-5 py-4 text-sm text-muted">
400 {working
401 ? "No description yet. It is written when the pull request is marked ready for review."
402 : "No description."}
403 </p>
404 )}
Acceptance checks in sandboxes, line comments and review verdicts405 <Comments
406 comments={comments}
407 canComment={Boolean(viewer)}
408 review={{
409 changesUrl: here + "?tab=changes",
410 // Nobody reviews their own pull request.
411 canJudge: active && viewer != null && viewer.id !== pull.author.id,
412 }}
413 />
Issues and pull requests replace intents and attempts414 </div>
415 )}
Acceptance checks in sandboxes, line comments and review verdicts416 {actionData?.action === "comment" && (
417 <div className="mt-2">
418 <ErrorText>{actionData.error}</ErrorText>
419 </div>
420 )}
Issues and pull requests replace intents and attempts421 </div>
422 </div>
423
424 <aside className="space-y-6">
Acceptance checks in sandboxes, line comments and review verdicts425 <ChecksPanel
426 run={checks}
427 commands={issue?.checks ?? []}
428 canRerun={canManage && pull.status === "open"}
429 />
430 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
431
Issues and pull requests replace intents and attempts432 {canMerge && pull.status === "open" && (
433 <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
434 <h3 className="text-sm font-medium">Merge this pull request</h3>
435 <p className="mt-1 text-xs text-muted">
436 Lands its commits on {defaultBranch}.
437 {issue?.state === "open" &&
438 ` Closes issue #${issue.number}, and any other pull requests still open for it.`}
439 </p>
440 <Form method="post" className="mt-3 space-y-3">
441 {issue?.state === "open" && (
442 <label className="flex items-start gap-2 text-xs text-muted">
443 <input type="checkbox" name="keepIssueOpen" className="mt-0.5 accent-accent" />
444 <span>
445 Keep #{issue.number} open. This is only part of the work.
446 </span>
447 </label>
448 )}
Acceptance checks in sandboxes, line comments and review verdicts449 {unchecked && (
450 <label className="flex items-start gap-2 text-xs text-muted">
451 <input type="checkbox" name="ignoreChecks" className="mt-0.5 accent-accent" />
452 <span>
453 Merge although the checks{" "}
454 {checking ? "have not finished" : "did not pass"}.
455 </span>
456 </label>
457 )}
Issues and pull requests replace intents and attempts458 <div className="*:w-full">
459 <Button variant="accent" type="submit" name="action" value="merge">
460 <GitMerge size={15} />
461 Merge into {defaultBranch}
462 </Button>
463 </div>
464 </Form>
465 {actionData?.action === "merge" && <ErrorText>{actionData.error}</ErrorText>}
466 </section>
467 )}
468
469 {canManage && active && (
470 <section className="rounded-xl border border-line bg-surface p-4">
471 <h3 className="text-sm font-medium">
472 {pull.status === "draft" ? "Ready for review" : "Update description"}
473 </h3>
474 <Form method="post" className="mt-3 space-y-2">
475 <Textarea
476 name="summary"
477 rows={4}
478 placeholder="What changed and why"
479 defaultValue={pull.body ?? ""}
480 />
481 <div className="flex flex-wrap gap-2">
482 <Button type="submit">
483 {pull.status === "draft" ? "Mark ready" : "Save"}
484 </Button>
485 <Button variant="quiet" type="submit" name="action" value="close">
486 Close pull request
487 </Button>
488 </div>
489 </Form>
Acceptance checks in sandboxes, line comments and review verdicts490 {actionData &&
491 !["merge", "comment", "recheck"].includes(String(actionData.action)) && (
Issues and pull requests replace intents and attempts492 <ErrorText>{actionData.error}</ErrorText>
493 )}
494 </section>
495 )}
496
497 <section>
498 <h3 className="text-sm font-medium">Working copy</h3>
Pull requests from branches499 {pull.branch ? (
500 <>
501 <p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
502 <GitBranch size={13} />
503 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
504 repository. Pushes to it show up here.
505 </p>
506 <div className="mt-2">
507 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
508 </div>
509 </>
510 ) : (
511 <>
512 <p className="mt-1 text-xs text-muted">
513 This fork belongs to the pull request. Pushes to it show up here.
514 </p>
515 <div className="mt-2">
516 <CopyLine text={`git clone ${remote}`} />
517 </div>
518 </>
519 )}
Issues and pull requests replace intents and attempts520 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
521 <GitCommitHorizontal size={13} />
522 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
523 </p>
524 </section>
525 </aside>
526 </div>
527 );
528}