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