Skip to content
681 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { Bot, ExternalLink, GitCommitHorizontal, GitMerge, Play, Sparkles } from "lucide-react";
3import { Form, Link, redirect } from "react-router";
4
5import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts";
6
7import type { Route } from "./+types/issue";
8import { excerpt, page } from "../../lib/meta";
9import { Markdown } from "../../components/markdown";
10import { AgentStepLine } from "../../components/agents";
11import {
12 Avatar,
13 CopyLine,
14 EmptyState,
15 ComputeNote,
16 ErrorText,
17 Input,
18 SubmitButton,
19 Textarea,
20 TimeAgo,
21} from "../../components/ui";
22import { CheckBadge } from "../../components/checks";
23import { LabelChip, LabelsBox, MilestoneBox } from "../../components/labels";
24import {
25 Assignee,
26 AssigneeStack,
27 ChangeSize,
28 CommentForm,
29 CommentList,
30 PeoplePicker,
31 IssueState,
32 PersonLink,
33 PullIcon,
34 plainText,
35} from "../../components/work";
36import { notFound } from "../../lib/not-found.server";
37import { openedBy } from "../../lib/opened-by";
38import { computeNoteFor } from "../../lib/compute.server";
39import { agents, identity, inbox, integrations, work } from "../../lib/services.server";
40import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
41import { accessTo, refusal, repoFor } from "../../lib/access.server";
42import { SubscriptionBox } from "../../components/notifications";
43import { useRefreshWhile } from "../../lib/refresh";
44
45
46export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
47 const issue = loaderData?.issue;
48 const title = issue ? `${issue.title} · Issue #${issue.number} · ` : "";
49 const state = issue?.state === "open" ? "Open" : issue?.reason === "not_planned" ? "Closed as not planned" : "Closed";
50 const body = excerpt(issue?.body);
51 return page(args, {
52 title: `${title}${params.owner}/${params.repo} · g1t`,
53 description: issue
54 ? `${state} issue #${issue.number} on ${params.owner}/${params.repo}, opened by ${issue.author.username}${issue.requestedBy ? ` for ${issue.requestedBy.username}` : ""}.${body ? ` ${body}` : ""}`
55 : null,
56 // The card shows the title and the state.
57 version: issue ? [issue.title, issue.state, issue.reason] : undefined,
58 type: "article",
59 });
60}
61
62export async function loader({ params, context }: Route.LoaderArgs) {
63 const viewer = getViewer(context);
64 const path = { namespace: params.owner, name: params.repo };
65 const number = Number(params.number);
66 // At once: only the plan's note waits for the viewer's role. Putting an
67 // agent on it needs Write: Read cannot spend compute.
68 const access = accessTo(context, params);
69 const [{ can }, found, labels, milestones, agentsEnabled, members, links, computeNote, subscription, active, teams] = await Promise.all([
70 access,
71 work.getIssue(path, number, viewer),
72 work.listLabels(path, viewer),
73 // Open ones to choose from, and the one it is in, with its progress.
74 work.listMilestones(path, viewer),
75 env.RUNNER.enabled(viewer, path),
76 // A member picks assignees from the workspace's people.
77 roleIn(viewer, params.owner) ? identity.listMembers(params.owner, viewer) : null,
78 // What it is tied to outside g1t. Shown only once the issue is known visible.
79 integrations.links(path, number).catch(() => []),
80 // Before a member assigns g1t: whether the workspace's plan lets it start.
81 access.then(({ can }) => (can.run ? computeNoteFor(params.owner, "agent") : null)),
82 // Whether the viewer hears of it, for the sidebar's Notifications.
83 viewer
84 ? repoFor(context, params).then((repo) =>
85 repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null,
86 )
87 : null,
88 // The project's agents at work, for the line under the pull request
89 // one is on for this issue: with the page, not after it.
90 agents.listRuns(viewer, { repo: path, active: true, limit: 50 }).catch(() => null),
91 // The teams a member can mention, for the comment box.
92 roleIn(viewer, params.owner) ? identity.listTeams(viewer, params.owner).catch(() => null) : null,
93 ]);
94 if (!found.ok) {
95 // Issues and pull requests share numbers; this one may be a pull request.
96 const pull = await work.getPull(path, number, viewer);
97 if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`);
98 throw notFound("issue");
99 }
100 const { issue } = found.value;
101 return {
102 ...found.value,
103 viewer,
104 labels: labels.ok ? labels.value : [],
105 milestones: milestones.ok ? milestones.value : [],
106 agentsEnabled,
107 computeNote,
108 links,
109 subscription,
110 // As the project's agents.json has them; left out, the line fetches them.
111 activeRuns: active?.ok ? { runs: active.value, member: can.run } : undefined,
112 members: members?.ok ? members.value.map((member) => member.username) : [],
113 handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [],
114 // The author can close and reopen their own issue, and whoever g1t's
115 // agent filed one for, that one; Triage and up, anyone's.
116 canManage: viewer != null && (viewer.id === workOwner(issue).id || can.triage),
117 // Maintain and up edit and delete anyone's comments; everyone, their own.
118 canModerate: can.manage_settings,
119 can,
120 };
121}
122
123export async function action({ request, params, context }: Route.ActionArgs) {
124 assertSameOrigin(request);
125 const user = requireUser(context, request);
126 const form = await request.formData();
127 const path = { namespace: params.owner, name: params.repo };
128 const number = Number(params.number);
129 // What each form needs; closing your own issue is checked by work.
130 // Labels: its author may use the repository's on their own issue, and
131 // work says so; anyone else, and every milestone, needs Triage.
132 const needed =
133 form.get("action") === "run-hosted"
134 ? "run"
135 : form.get("action") === "assign" || form.get("action") === "milestone"
136 ? "triage"
137 : null;
138 const refused = needed ? await refusal(context, params, needed) : null;
139 if (refused) return { error: refused, action: String(form.get("action")) };
140
141 switch (form.get("action")) {
142 case "run-hosted": {
143 const result = await env.RUNNER.run(user, path, number, {
144 instructions: String(form.get("instructions") ?? ""),
145 });
146 return result.ok ? null : { error: result.error.message, action: "run-hosted" };
147 }
148 case "open-pull": {
149 const result = await work.openPull(user, path, {
150 issue: number,
151 agent: String(form.get("agent") ?? ""),
152 runtime: "external",
153 });
154 if (!result.ok) return { error: result.error.message };
155 throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`);
156 }
157 case "comment": {
158 const result = await work.addComment(user, path, number, {
159 body: String(form.get("body") ?? ""),
160 });
161 return result.ok ? null : { error: result.error.message };
162 }
163 case "assign": {
164 const result = await work.updateIssue(user, path, number, {
165 assignees: [
166 ...form.getAll("assignee").map(String),
167 ...String(form.get("others") ?? "").split(/[\s,]+/),
168 ],
169 });
170 return result.ok ? null : { error: result.error.message };
171 }
172 case "labels": {
173 const result = await work.setLabels(user, path, number, form.getAll("label").map(String));
174 return result.ok ? null : { error: result.error.message, action: "labels" };
175 }
176 case "milestone": {
177 const result = await work.updateIssue(user, path, number, { milestone: Number(form.get("milestone")) || 0 });
178 return result.ok ? null : { error: result.error.message, action: "milestone" };
179 }
180 case "reopen": {
181 const result = await work.reopenIssue(user, path, number);
182 return result.ok ? null : { error: result.error.message };
183 }
184 // A comment's own text, changed or taken away: its author's to do, or
185 // a maintainer's, which work checks.
186 case "edit-comment":
187 case "delete-comment": {
188 const action = String(form.get("action"));
189 const id = String(form.get("comment") ?? "");
190 const result =
191 action === "edit-comment"
192 ? await work.editComment(user, path, id, String(form.get("body") ?? ""))
193 : await work.deleteComment(user, path, id);
194 return result.ok ? null : { error: result.error.message, action, comment: id };
195 }
196 default: {
197 const result = await work.closeIssue(
198 user,
199 path,
200 number,
201 form.get("action") === "close-not-planned" ? "not_planned" : "completed",
202 );
203 return result.ok ? null : { error: result.error.message };
204 }
205 }
206}
207
208/** What became of a pull request, in a few words. */
209function outcome(pull: Pull): string {
210 if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`;
211 if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`;
212 if (pull.status === "closed") return "Closed without merging";
213 if (pull.status === "draft") return "Draft · in progress";
214 return "Ready for review";
215}
216
217function PullRow({ pull, base }: { pull: Pull; base: string }) {
218 const merged = pull.status === "merged";
219 const opener = openedBy(pull);
220 return (
221 <li>
222 <Link
223 to={`${base}/pull/${pull.number}`}
224 className={`block rounded-xl border p-4 transition-colors ${
225 merged
226 ? "border-merged/40 bg-merged/5 hover:border-merged/70"
227 : "border-line bg-surface hover:border-line-strong"
228 } ${pull.status === "closed" ? "opacity-70" : ""}`}
229 >
230 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
231 <PullIcon status={pull.status} />
232 <span className="font-medium">
233 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
234 </span>
235 {pull.status === "draft" && (
236 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
237 )}
238 <span className="ml-auto flex items-center gap-3 text-xs text-faint">
239 <ChangeSize files={pull.files} />
240 <CheckBadge status={pull.checkStatus} />
241 <span className="flex items-center gap-1 font-mono">
242 <GitCommitHorizontal size={13} />
243 {pull.headCommit?.slice(0, 7) ?? "no commits"}
244 </span>
245 <TimeAgo at={pull.updatedAt} />
246 </span>
247 </div>
248 <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted">
249 <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span>
250 <span className="text-faint">·</span>
251 <span className="flex items-center gap-1 font-mono">
252 <Bot size={12} />
253 {pull.agent}
254 </span>
255 {opener.requestedBy ? (
256 <span className="text-faint">· requested by {opener.requestedBy}</span>
257 ) : (
258 <>
259 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
260 <span className="text-faint">· opened by {opener.name}</span>
261 </>
262 )}
263 </p>
264 {pull.body && (
265 <p className="mt-2 line-clamp-2 text-sm text-muted">{plainText(pull.body)}</p>
266 )}
267 </Link>
268 </li>
269 );
270}
271
272export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) {
273 const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData;
274
275 // Follow agents at work without a manual reload.
276 const running = pulls.some(
277 (pull) =>
278 pull.status === "draft" ||
279 pull.checkStatus === "queued" ||
280 pull.checkStatus === "running",
281 );
282 // The pull request g1t's agent has in progress for this issue, if any.
283 const assigned = [...pulls]
284 .reverse()
285 .find(
286 (pull) =>
287 pull.agent === "g1t" &&
288 pull.runtime === "hosted" &&
289 (pull.status === "draft" || pull.status === "open"),
290 );
291 useRefreshWhile(Boolean(running || assigned));
292
293 // A refusal to start g1t shows by its button; every other error below the discussion.
294 const runError = actionData && "action" in actionData && actionData.action === "run-hosted";
295 const base = `/${params.owner}/${params.repo}`;
296 const open = issue.state === "open";
297 const reference = `${params.owner}/${params.repo}#${issue.number}`;
298 const resolver = pulls.find((pull) => pull.number === issue.resolvedBy);
299 // The merged pull request first, then the ones still in play, then the rest.
300 const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const;
301 const ordered = [...pulls].sort(
302 (a, b) => rank[a.status] - rank[b.status] || a.number - b.number,
303 );
304
305 return (
306 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
307 <div className="min-w-0">
308 <h2 className="text-2xl font-semibold tracking-tight text-balance">
309 {issue.title} <span className="font-normal text-faint">#{issue.number}</span>
310 </h2>
311 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
312 <IssueState issue={issue} />
313 <span className="flex items-center gap-2">
314 <Avatar name={issue.author.username} size={18} />
315 <span>
316 <PersonLink name={issue.author.username} className="font-medium text-fg hover:underline" /> opened this{" "}
317 {issue.requestedBy && (
318 <>
319 for{" "}
320 <PersonLink name={issue.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "}
321 </>
322 )}
323 <TimeAgo at={issue.createdAt} />
324 </span>
325 </span>
326 {issue.labels.map((name) => (
327 <LabelChip key={name} name={name} color={labels.find((label) => label.name === name)?.color} />
328 ))}
329 {open && issue.agent && <Assignee agent={issue.agent} />}
330 </div>
331
332 {issue.resolvedBy != null && (
333 <Link
334 to={`${base}/pull/${issue.resolvedBy}`}
335 className="mt-5 flex items-center gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70"
336 >
337 <GitMerge size={18} className="shrink-0 text-merged" />
338 <span>
339 Resolved by{" "}
340 <span className="font-medium">
341 {resolver?.title ?? "pull request"} #{issue.resolvedBy}
342 </span>
343 {resolver?.mergedBy && (
344 <span className="text-muted">
345 , merged by {resolver.mergedBy}{" "}
346 {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />}
347 </span>
348 )}
349 </span>
350 </Link>
351 )}
352
353 {issue.body && (
354 <div className="mt-5 rounded-xl border border-line bg-surface p-5">
355 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
356 </div>
357 )}
358
359 <div className="mt-10 flex items-baseline justify-between">
360 <h3 className="font-semibold tracking-tight">Pull requests</h3>
361 <p className="text-sm text-muted">
362 {pulls.length === 0
363 ? "None yet"
364 : `${pulls.length} for this issue${
365 pulls.some((pull) => pull.status === "merged") ? "" : ", none merged"
366 }`}
367 </p>
368 </div>
369 <div className="mt-3">
370 {pulls.length === 0 ? (
371 <EmptyState title="Nobody has worked on this yet">
372 Put g1t on it, or point your own agent at{" "}
373 <code className="font-mono">{reference}</code>.
374 </EmptyState>
375 ) : (
376 <ol className="space-y-3">
377 {ordered.map((pull) => (
378 <PullRow key={pull.id} pull={pull} base={base} />
379 ))}
380 </ol>
381 )}
382 </div>
383
384 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
385 <div className="mt-3">
386 <div className="space-y-4">
387 <CommentList
388 comments={comments}
389 base={base}
390 viewerId={viewer?.id ?? null}
391 canModerate={loaderData.canModerate}
392 failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null}
393 />
394 <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} />
395 {canManage && (
396 <Form method="post" className="flex flex-wrap justify-end gap-2">
397 {open ? (
398 <>
399 <SubmitButton variant="quiet" name="action" value="close-not-planned" pending="Closing…">
400 Close as not planned
401 </SubmitButton>
402 <SubmitButton variant="quiet" name="action" value="close-completed" pending="Closing…">
403 Close issue
404 </SubmitButton>
405 </>
406 ) : (
407 <SubmitButton variant="quiet" name="action" value="reopen" pending="Reopening…">
408 Reopen issue
409 </SubmitButton>
410 )}
411 </Form>
412 )}
413 </div>
414 </div>
415 <div className="mt-2">
416 {/* A refused comment edit says so under the comment. */}
417 {!runError && !(actionData && "comment" in actionData) && <ErrorText>{actionData?.error}</ErrorText>}
418 </div>
419 </div>
420
421 <aside className="space-y-6">
422 {loaderData.links.length > 0 && (
423 <section>
424 <h3 className="text-sm font-medium">From outside g1t</h3>
425 <ul className="mt-2 space-y-1.5 text-sm">
426 {loaderData.links.map((link) => (
427 <li key={`${link.connectionId}-${link.key}`}>
428 <a
429 href={link.url}
430 target="_blank"
431 rel="noreferrer"
432 className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong"
433 >
434 <span className="min-w-0 grow">
435 <span className="block truncate font-medium">
436 {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span>
437 </span>
438 <span className="block text-xs text-faint">
439 {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "}
440 <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} />
441 </span>
442 </span>
443 <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" />
444 </a>
445 </li>
446 ))}
447 </ul>
448 </section>
449 )}
450 <section>
451 <h3 className="text-sm font-medium">Assignees</h3>
452 <ul className="mt-2 space-y-1.5 text-sm">
453 {open && assigned && (
454 <li>
455 <Link
456 to={`${base}/pull/${assigned.number}`}
457 className="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-2.5 py-2 transition-colors hover:border-accent/60"
458 >
459 <Sparkles size={15} className="shrink-0 text-accent" />
460 <span className="min-w-0 grow">
461 <span className="block font-mono text-xs font-medium">g1t</span>
462 <span className="block truncate text-xs text-muted">
463 {assigned.status === "draft"
464 ? "Making the change"
465 : "Seeing it through checks and review"}{" "}
466 · #{assigned.number}
467 </span>
468 <AgentStepLine owner={params.owner} repo={params.repo} number={assigned.number} runs={loaderData.activeRuns} />
469 </span>
470 <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-accent" />
471 </Link>
472 </li>
473 )}
474 {issue.assignees.map((name) => (
475 <li key={name} className="flex items-center gap-2 px-1">
476 <Avatar name={name} size={20} />
477 <span className="grow truncate font-mono text-xs">
478 <PersonLink name={name} className="hover:underline" />
479 </span>
480 </li>
481 ))}
482 {open && issue.queued && !assigned && (
483 <li className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2">
484 <Sparkles size={15} className="shrink-0 text-faint" />
485 <span className="min-w-0 grow">
486 <span className="block font-mono text-xs font-medium">g1t</span>
487 <span className="block text-xs text-muted">
488 {issue.blockedBy.length > 0
489 ? "Queued. Starts when what this depends on has merged."
490 : "Queued. Starts as soon as there is room."}
491 </span>
492 </span>
493 </li>
494 )}
495 {!open && resolver && resolver.agent === "g1t" && (
496 <li>
497 <Link
498 to={`${base}/pull/${resolver.number}`}
499 className="flex items-center gap-2 rounded-lg border border-line bg-surface px-2.5 py-2 transition-colors hover:border-line-strong"
500 >
501 <Sparkles size={15} className="shrink-0 text-accent" />
502 <span className="min-w-0 grow">
503 <span className="block font-mono text-xs font-medium">g1t</span>
504 <span className="block truncate text-xs text-muted">
505 Resolved it with #{resolver.number}
506 </span>
507 </span>
508 </Link>
509 </li>
510 )}
511 {issue.assignees.length === 0 &&
512 !(open && (assigned || issue.queued)) &&
513 !(!open && resolver?.agent === "g1t") && (
514 <li className="px-1 text-xs text-faint">No one yet.</li>
515 )}
516 </ul>
517 {issue.blockedBy.length > 0 && (
518 <p className="mt-3 text-xs text-muted">
519 Depends on{" "}
520 {issue.blockedBy.map((number, index) => (
521 <span key={number}>
522 {index > 0 && ", "}
523 <Link
524 to={`${base}/issues/${number}`}
525 className="font-medium text-fg hover:underline"
526 >
527 #{number}
528 </Link>
529 </span>
530 ))}
531 {open ? ", which has to merge first." : "."}
532 </p>
533 )}
534
535 {open && agentsEnabled && can.run && !assigned && !issue.queued && (
536 <Form method="post" className="mt-3 space-y-2">
537 <input type="hidden" name="action" value="run-hosted" />
538 <div className="*:w-full">
539 <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…">
540 <Sparkles size={14} />
541 Assign to g1t
542 </SubmitButton>
543 </div>
544 <details>
545 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
546 Add guidance for this run
547 </summary>
548 <div className="mt-2">
549 <Textarea
550 name="instructions"
551 rows={2}
552 placeholder="On top of the issue's description"
553 />
554 </div>
555 </details>
556 <p className="text-xs text-muted">
557 It opens a pull request and sees it through checks, a review by another
558 agent and fixes. You get it back ready to merge.
559 </p>
560 <ComputeNote note={loaderData.computeNote} />
561 {runError && <ErrorText>{actionData.error}</ErrorText>}
562 </Form>
563 )}
564
565 {open && !agentsEnabled && can.run && !assigned && !issue.queued && (
566 // Where g1t's agent would be, and what makes it appear.
567 <div className="mt-3 rounded-lg border border-dashed border-line p-3 text-sm">
568 <p className="flex items-center gap-1.5 font-medium">
569 <Sparkles size={14} className="text-accent" />
570 g1t
571 </p>
572 <p className="mt-1 text-xs text-muted">
573 Connect a model provider and g1t can take this issue: it opens a pull request and sees it
574 through checks, review and fixes. Agents run on a paid workspace, or on the free trial.
575 </p>
576 <Link
577 to={`/${params.owner}/-/integrations`}
578 className="mt-2 inline-block text-xs text-accent hover:underline"
579 >
580 Connect a model
581 </Link>
582 </div>
583 )}
584
585 {viewer && can.triage && open && (
586 <div className="mt-3 space-y-2">
587 {!issue.assignees.includes(viewer.username) && (
588 <Form method="post">
589 <input type="hidden" name="action" value="assign" />
590 {issue.assignees.map((name) => (
591 <input key={name} type="hidden" name="assignee" value={name} />
592 ))}
593 <input type="hidden" name="assignee" value={viewer.username} />
594 <div className="*:w-full">
595 <SubmitButton variant="quiet" name="who" value="self" pending="Assigning…">
596 Assign yourself
597 </SubmitButton>
598 </div>
599 </Form>
600 )}
601 <details className="group">
602 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
603 Assign people
604 </summary>
605 <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}>
606 <input type="hidden" name="action" value="assign" />
607 <PeoplePicker name="assignee" members={members} chosen={issue.assignees} />
608 <SubmitButton variant="quiet" name="who" value="picked" pending="Saving…">
609 Save assignees
610 </SubmitButton>
611 </Form>
612 </details>
613 </div>
614 )}
615 </section>
616
617 {/* Another way to start the work: not once a pull request is doing it. */}
618 {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && (
619 <section className="rounded-xl border border-line bg-surface p-4">
620 <h3 className="text-sm font-medium">Bring your own agent</h3>
621 <p className="mt-1 text-xs text-muted">
622 With g1t connected to your agent, ask it to work on this issue.
623 </p>
624 <div className="mt-3">
625 <CopyLine text={reference} />
626 </div>
627 {viewer ? (
628 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
629 <input type="hidden" name="action" value="open-pull" />
630 <p className="text-xs text-muted">
631 Or open a draft pull request yourself and get a fork to push to.
632 </p>
633 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
634 <div className="*:w-full">
635 <SubmitButton match={{ action: "open-pull" }} pending="Opening…">
636 Open pull request
637 </SubmitButton>
638 </div>
639 <p className="text-xs text-muted">
640 Already pushed a branch?{" "}
641 <Link
642 to={`${base}/pulls/new?issue=${issue.number}`}
643 className="text-fg underline underline-offset-4"
644 >
645 Open a pull request from it
646 </Link>
647 .
648 </p>
649 </Form>
650 ) : (
651 <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
652 <Link to="/login" className="text-fg underline underline-offset-4">
653 Sign in
654 </Link>{" "}
655 to open a pull request.
656 </p>
657 )}
658 </section>
659 )}
660
661 <LabelsBox
662 labels={labels}
663 chosen={issue.labels}
664 canEdit={loaderData.canManage}
665 canCreate={can.manage_labels}
666 manageUrl={can.manage_labels ? `${base}/labels` : undefined}
667 />
668 <MilestoneBox
669 milestones={loaderData.milestones}
670 current={issue.milestone}
671 canEdit={can.triage}
672 base={base}
673 />
674
675 {viewer && (
676 <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} />
677 )}
678 </aside>
679 </div>
680 );
681}