Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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