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