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.

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