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