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

528 lines19,654 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import {
3 Bot,
4 ChevronRight,
5 FileDiff,
6 GitBranch,
7 GitCommitHorizontal,
8 CircleCheck,
9 CircleSlash,
10 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
20import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
21
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";
34import { ChecksPanel } from "../../components/checks";
35import { Comments, IssueIcon, PullState, verdicts } from "../../components/work";
36import { 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;
69 const range = pullComparison(pull);
70 const [session, comparison] = await Promise.all([
71 tab === "session" ? work.readSession(path, number, viewer) : null,
72 tab === "changes"
73 ? repos.compare(range.repoId, viewer, range.base, range.head)
74 : null,
75 ]);
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");
100 const verdict = form.get("verdict");
101 const line = Number(form.get("line"));
102 const result =
103 action === "merge"
104 ? await work.mergePull(user, path, number, {
105 keepIssueOpen: form.get("keepIssueOpen") === "on",
106 ignoreChecks: form.get("ignoreChecks") === "on",
107 })
108 : action === "close"
109 ? await work.closePull(user, path, number)
110 : 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") ?? ""));
121 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,
229 checks,
230 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}`;
240 const remote = pull.fork
241 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
242 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
243 const active = pull.status === "draft" || pull.status === "open";
244
245 // Follow an agent at work, or checks in progress, without a manual reload.
246 const revalidator = useRevalidator();
247 const working = pull.status === "draft";
248 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";
252 useEffect(() => {
253 if (!working && !checking) return;
254 const timer = setInterval(() => {
255 if (document.visibilityState === "visible") revalidator.revalidate();
256 }, REFRESH_MS);
257 return () => clearInterval(timer);
258 }, [working, checking, revalidator]);
259
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>{" "}
272 {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>
280 </span>
281 </span>
282 {/* 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 )}
290 </div>
291
292 {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
309 {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 ? (
372 <DiffView
373 comparison={comparison}
374 review={{
375 comments: comments.filter((comment) => comment.path),
376 canComment: Boolean(viewer),
377 }}
378 />
379 ) : 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 )}
405 <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 />
414 </div>
415 )}
416 {actionData?.action === "comment" && (
417 <div className="mt-2">
418 <ErrorText>{actionData.error}</ErrorText>
419 </div>
420 )}
421 </div>
422 </div>
423
424 <aside className="space-y-6">
425 <ChecksPanel
426 run={checks}
427 commands={issue?.checks ?? []}
428 canRerun={canManage && pull.status === "open"}
429 />
430 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
431
432 {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 )}
449 {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 )}
458 <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>
490 {actionData &&
491 !["merge", "comment", "recheck"].includes(String(actionData.action)) && (
492 <ErrorText>{actionData.error}</ErrorText>
493 )}
494 </section>
495 )}
496
497 <section>
498 <h3 className="text-sm font-medium">Working copy</h3>
499 {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 )}
520 <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}