| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { Bot, GitCommitHorizontal, GitMerge, Play, Sparkles, Terminal } from "lucide-react"; |
| 3 | import { useEffect } from "react"; |
| 4 | import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router"; |
| 5 | |
| 6 | import type { Pull } from "@g1t/contracts"; |
| 7 | |
| 8 | import type { Route } from "./+types/issue"; |
| 9 | import { Markdown } from "../../components/markdown"; |
| 10 | import { |
| 11 | Avatar, |
| 12 | Button, |
| 13 | CopyLine, |
| 14 | EmptyState, |
| 15 | ErrorText, |
| 16 | Input, |
| 17 | Textarea, |
| 18 | TimeAgo, |
| 19 | } from "../../components/ui"; |
| 20 | import { Comments, IssueState, Label, PullIcon } from "../../components/work"; |
| 21 | import { work } from "../../lib/services.server"; |
| 22 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; |
| 23 | |
| 24 | const REFRESH_MS = 4000; |
| 25 | |
| 26 | export function meta({ loaderData, params }: Route.MetaArgs) { |
| 27 | const title = loaderData ? `${loaderData.issue.title} · Issue #${loaderData.issue.number} · ` : ""; |
| 28 | return [{ title: `${title}${params.owner}/${params.repo} · g1t` }]; |
| 29 | } |
| 30 | |
| 31 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 32 | const viewer = getViewer(context); |
| 33 | const path = { namespace: params.owner, name: params.repo }; |
| 34 | const number = Number(params.number); |
| 35 | const found = await work.getIssue(path, number, viewer); |
| 36 | if (!found.ok) { |
| 37 | // Issues and pull requests share numbers; this one may be a pull request. |
| 38 | const pull = await work.getPull(path, number, viewer); |
| 39 | if (pull.ok) throw redirect(`/${params.owner}/${params.repo}/pull/${number}`); |
| 40 | throw new Response("Issue not found.", { status: 404 }); |
| 41 | } |
| 42 | const [labels, agentModels] = await Promise.all([ |
| 43 | work.listLabels(path, viewer), |
| 44 | env.RUNNER.models(viewer), |
| 45 | ]); |
| 46 | const { issue } = found.value; |
| 47 | return { |
| 48 | ...found.value, |
| 49 | viewer, |
| 50 | labels: labels.ok ? labels.value : [], |
| 51 | agentModels, |
| 52 | // The author and members of the workspace can change an issue. |
| 53 | canManage: |
| 54 | viewer != null && |
| 55 | (viewer.id === issue.author.id || |
| 56 | (viewer.workspaces ?? []).some((membership) => membership.slug === params.owner)), |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 61 | assertSameOrigin(request); |
| 62 | const user = requireUser(context, request); |
| 63 | const form = await request.formData(); |
| 64 | const path = { namespace: params.owner, name: params.repo }; |
| 65 | const number = Number(params.number); |
| 66 | |
| 67 | switch (form.get("action")) { |
| 68 | case "run-hosted": { |
| 69 | const result = await env.RUNNER.run(user, path, number, { |
| 70 | count: Number(form.get("count")), |
| 71 | instructions: String(form.get("instructions") ?? ""), |
| 72 | model: String(form.get("model") ?? "") || undefined, |
| 73 | }); |
| 74 | return result.ok ? null : { error: result.error.message }; |
| 75 | } |
| 76 | case "open-pull": { |
| 77 | const result = await work.openPull(user, path, { |
| 78 | issue: number, |
| 79 | agent: String(form.get("agent") ?? ""), |
| 80 | runtime: "external", |
| 81 | }); |
| 82 | if (!result.ok) return { error: result.error.message }; |
| 83 | throw redirect(`/${params.owner}/${params.repo}/pull/${result.value.number}`); |
| 84 | } |
| 85 | case "comment": { |
| 86 | const result = await work.addComment(user, path, number, String(form.get("body") ?? "")); |
| 87 | return result.ok ? null : { error: result.error.message }; |
| 88 | } |
| 89 | case "labels": { |
| 90 | const result = await work.updateIssue(user, path, number, { |
| 91 | labels: [ |
| 92 | ...form.getAll("label").map(String), |
| 93 | ...String(form.get("labels") ?? "").split(","), |
| 94 | ], |
| 95 | }); |
| 96 | return result.ok ? null : { error: result.error.message }; |
| 97 | } |
| 98 | case "reopen": { |
| 99 | const result = await work.reopenIssue(user, path, number); |
| 100 | return result.ok ? null : { error: result.error.message }; |
| 101 | } |
| 102 | default: { |
| 103 | const result = await work.closeIssue( |
| 104 | user, |
| 105 | path, |
| 106 | number, |
| 107 | form.get("action") === "close-not-planned" ? "not_planned" : "completed", |
| 108 | ); |
| 109 | return result.ok ? null : { error: result.error.message }; |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /** What became of a pull request, in a few words. */ |
| 115 | function outcome(pull: Pull): string { |
| 116 | if (pull.status === "merged") return `Merged by ${pull.mergedBy ?? "someone"}`; |
| 117 | if (pull.supersededBy != null) return `Closed · #${pull.supersededBy} was merged instead`; |
| 118 | if (pull.status === "closed") return "Closed without merging"; |
| 119 | if (pull.status === "draft") return "Draft · in progress"; |
| 120 | return "Ready for review"; |
| 121 | } |
| 122 | |
| 123 | function PullRow({ pull, base }: { pull: Pull; base: string }) { |
| 124 | const merged = pull.status === "merged"; |
| 125 | return ( |
| 126 | <li> |
| 127 | <Link |
| 128 | to={`${base}/pull/${pull.number}`} |
| 129 | className={`block rounded-xl border p-4 transition-colors ${ |
| 130 | merged |
| 131 | ? "border-merged/40 bg-merged/5 hover:border-merged/70" |
| 132 | : "border-line bg-surface hover:border-line-strong" |
| 133 | } ${pull.status === "closed" ? "opacity-70" : ""}`} |
| 134 | > |
| 135 | <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> |
| 136 | <PullIcon status={pull.status} /> |
| 137 | <span className="font-medium"> |
| 138 | {pull.title} <span className="font-normal text-faint">#{pull.number}</span> |
| 139 | </span> |
| 140 | {pull.status === "draft" && ( |
| 141 | <span className="size-1.5 animate-pulse rounded-full bg-accent" /> |
| 142 | )} |
| 143 | <span className="ml-auto flex items-center gap-3 text-xs text-faint"> |
| 144 | <span className="flex items-center gap-1 font-mono"> |
| 145 | <GitCommitHorizontal size={13} /> |
| 146 | {pull.headCommit?.slice(0, 7) ?? "no commits"} |
| 147 | </span> |
| 148 | <TimeAgo at={pull.updatedAt} /> |
| 149 | </span> |
| 150 | </div> |
| 151 | <p className="mt-1.5 flex flex-wrap items-center gap-x-2 text-xs text-muted"> |
| 152 | <span className={merged ? "font-medium text-merged" : ""}>{outcome(pull)}</span> |
| 153 | <span className="text-faint">·</span> |
| 154 | <span className="flex items-center gap-1 font-mono"> |
| 155 | <Bot size={12} /> |
| 156 | {pull.agent} |
| 157 | </span> |
| 158 | {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>} |
| 159 | <span className="text-faint">· opened by {pull.author.username}</span> |
| 160 | </p> |
| 161 | {pull.body && <p className="mt-2 line-clamp-2 text-sm text-muted">{pull.body}</p>} |
| 162 | </Link> |
| 163 | </li> |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) { |
| 168 | const { issue, pulls, comments, viewer, labels, agentModels, canManage } = loaderData; |
| 169 | |
| 170 | // Follow agents at work without a manual reload. |
| 171 | const revalidator = useRevalidator(); |
| 172 | const navigation = useNavigation(); |
| 173 | const running = pulls.some((pull) => pull.status === "draft"); |
| 174 | useEffect(() => { |
| 175 | if (!running) return; |
| 176 | const timer = setInterval(() => { |
| 177 | if (document.visibilityState === "visible") revalidator.revalidate(); |
| 178 | }, REFRESH_MS); |
| 179 | return () => clearInterval(timer); |
| 180 | }, [running, revalidator]); |
| 181 | |
| 182 | const starting = navigation.formData?.get("action") === "run-hosted"; |
| 183 | const base = `/${params.owner}/${params.repo}`; |
| 184 | const open = issue.state === "open"; |
| 185 | const reference = `${params.owner}/${params.repo}#${issue.number}`; |
| 186 | const resolver = pulls.find((pull) => pull.number === issue.resolvedBy); |
| 187 | // The merged pull request first, then the ones still in play, then the rest. |
| 188 | const rank = { merged: 0, open: 1, draft: 2, closed: 3 } as const; |
| 189 | const ordered = [...pulls].sort( |
| 190 | (a, b) => rank[a.status] - rank[b.status] || a.number - b.number, |
| 191 | ); |
| 192 | |
| 193 | return ( |
| 194 | <div className="grid gap-8 lg:grid-cols-[1fr_19rem]"> |
| 195 | <div className="min-w-0"> |
| 196 | <h2 className="text-2xl font-semibold tracking-tight text-balance"> |
| 197 | {issue.title} <span className="font-normal text-faint">#{issue.number}</span> |
| 198 | </h2> |
| 199 | <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted"> |
| 200 | <IssueState issue={issue} /> |
| 201 | <span className="flex items-center gap-2"> |
| 202 | <Avatar name={issue.author.username} size={18} /> |
| 203 | <span> |
| 204 | <span className="font-medium text-fg">{issue.author.username}</span> opened this{" "} |
| 205 | <TimeAgo at={issue.createdAt} /> |
| 206 | </span> |
| 207 | </span> |
| 208 | {issue.labels.map((name) => ( |
| 209 | <Label key={name} name={name} /> |
| 210 | ))} |
| 211 | </div> |
| 212 | |
| 213 | {issue.resolvedBy != null && ( |
| 214 | <Link |
| 215 | to={`${base}/pull/${issue.resolvedBy}`} |
| 216 | className="mt-5 flex 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" |
| 217 | > |
| 218 | <GitMerge size={18} className="shrink-0 text-merged" /> |
| 219 | <span> |
| 220 | Resolved by{" "} |
| 221 | <span className="font-medium"> |
| 222 | {resolver?.title ?? "pull request"} #{issue.resolvedBy} |
| 223 | </span> |
| 224 | {resolver?.mergedBy && ( |
| 225 | <span className="text-muted"> |
| 226 | , merged by {resolver.mergedBy}{" "} |
| 227 | {resolver.mergedAt && <TimeAgo at={resolver.mergedAt} />} |
| 228 | </span> |
| 229 | )} |
| 230 | </span> |
| 231 | </Link> |
| 232 | )} |
| 233 | |
| 234 | {issue.body && ( |
| 235 | <div className="mt-5 rounded-xl border border-line bg-surface p-5"> |
| 236 | <Markdown source={issue.body} /> |
| 237 | </div> |
| 238 | )} |
| 239 | |
| 240 | <div className="mt-10 flex items-baseline justify-between"> |
| 241 | <h3 className="font-semibold tracking-tight">Pull requests</h3> |
| 242 | <p className="text-sm text-muted"> |
| 243 | {pulls.length === 0 |
| 244 | ? "None yet" |
| 245 | : `${pulls.length} for this issue${ |
| 246 | pulls.some((pull) => pull.status === "merged") ? "" : ", none merged" |
| 247 | }`} |
| 248 | </p> |
| 249 | </div> |
| 250 | <div className="mt-3"> |
| 251 | {pulls.length === 0 ? ( |
| 252 | <EmptyState title="Nobody has worked on this yet"> |
| 253 | Put g1t agents on it, or point your own agent at{" "} |
| 254 | <code className="font-mono">{reference}</code>. |
| 255 | </EmptyState> |
| 256 | ) : ( |
| 257 | <ol className="space-y-3"> |
| 258 | {ordered.map((pull) => ( |
| 259 | <PullRow key={pull.id} pull={pull} base={base} /> |
| 260 | ))} |
| 261 | </ol> |
| 262 | )} |
| 263 | </div> |
| 264 | |
| 265 | <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3> |
| 266 | <div className="mt-3"> |
| 267 | <Comments comments={comments} canComment={Boolean(viewer)} /> |
| 268 | </div> |
| 269 | <div className="mt-2"> |
| 270 | <ErrorText>{actionData?.error}</ErrorText> |
| 271 | </div> |
| 272 | </div> |
| 273 | |
| 274 | <aside className="space-y-6"> |
| 275 | {open && agentModels.length > 0 && ( |
| 276 | <section className="rounded-xl border border-accent/30 bg-accent/5 p-4"> |
| 277 | <h3 className="flex items-center gap-2 text-sm font-medium"> |
| 278 | <Sparkles size={15} className="text-accent" /> |
| 279 | Assign g1t agents |
| 280 | </h3> |
| 281 | <p className="mt-1 text-xs text-muted"> |
| 282 | Each agent opens its own pull request for this issue and works in |
| 283 | its own sandbox, in parallel. Merge the one you want. |
| 284 | </p> |
| 285 | <Form method="post" className="mt-3 space-y-2"> |
| 286 | <input type="hidden" name="action" value="run-hosted" /> |
| 287 | <label className="flex items-center justify-between gap-3 text-sm"> |
| 288 | <span className="text-muted">Agents</span> |
| 289 | <select |
| 290 | name="count" |
| 291 | defaultValue="1" |
| 292 | className="rounded-md border border-line bg-bg px-2 py-1 text-sm" |
| 293 | > |
| 294 | {[1, 2, 3, 4, 5].map((count) => ( |
| 295 | <option key={count} value={count}> |
| 296 | {count} |
| 297 | </option> |
| 298 | ))} |
| 299 | </select> |
| 300 | </label> |
| 301 | <label className="flex items-center justify-between gap-3 text-sm"> |
| 302 | <span className="text-muted">Model</span> |
| 303 | <select |
| 304 | name="model" |
| 305 | className="rounded-md border border-line bg-bg px-2 py-1 text-sm" |
| 306 | > |
| 307 | {agentModels.map((model) => ( |
| 308 | <option key={model.id} value={model.id} title={model.description}> |
| 309 | {model.label} · {model.modelName} |
| 310 | </option> |
| 311 | ))} |
| 312 | </select> |
| 313 | </label> |
| 314 | <Textarea name="instructions" rows={2} placeholder="Extra guidance (optional)" /> |
| 315 | <div className="*:w-full"> |
| 316 | <Button variant="accent" type="submit" disabled={starting}> |
| 317 | <Play size={14} /> |
| 318 | {starting ? "Starting sandboxes…" : "Start"} |
| 319 | </Button> |
| 320 | </div> |
| 321 | </Form> |
| 322 | </section> |
| 323 | )} |
| 324 | |
| 325 | {open && ( |
| 326 | <section className="rounded-xl border border-line bg-surface p-4"> |
| 327 | <h3 className="text-sm font-medium">Bring your own agent</h3> |
| 328 | <p className="mt-1 text-xs text-muted"> |
| 329 | With g1t connected to your agent, ask it to work on this issue. |
| 330 | </p> |
| 331 | <div className="mt-3"> |
| 332 | <CopyLine text={reference} /> |
| 333 | </div> |
| 334 | {viewer ? ( |
| 335 | <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4"> |
| 336 | <input type="hidden" name="action" value="open-pull" /> |
| 337 | <p className="text-xs text-muted"> |
| 338 | Or open a draft pull request yourself and get a fork to push to. |
| 339 | </p> |
| 340 | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> |
| 341 | <div className="*:w-full"> |
| 342 | <Button type="submit">Open pull request</Button> |
| 343 | </div> |
| 344 | </Form> |
| 345 | ) : ( |
| 346 | <p className="mt-4 border-t border-line pt-4 text-sm text-muted"> |
| 347 | <Link to="/login" className="text-fg underline underline-offset-4"> |
| 348 | Sign in |
| 349 | </Link>{" "} |
| 350 | to open a pull request. |
| 351 | </p> |
| 352 | )} |
| 353 | </section> |
| 354 | )} |
| 355 | |
| 356 | {issue.checks.length > 0 && ( |
| 357 | <section> |
| 358 | <h3 className="text-sm font-medium">Acceptance checks</h3> |
| 359 | <ul className="mt-2 space-y-1.5"> |
| 360 | {issue.checks.map((check) => ( |
| 361 | <li |
| 362 | key={check} |
| 363 | className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs" |
| 364 | > |
| 365 | <Terminal size={13} className="shrink-0 text-faint" /> |
| 366 | <span className="truncate">{check}</span> |
| 367 | </li> |
| 368 | ))} |
| 369 | </ul> |
| 370 | </section> |
| 371 | )} |
| 372 | |
| 373 | {canManage && ( |
| 374 | <details className="group"> |
| 375 | <summary className="cursor-pointer list-none text-sm font-medium"> |
| 376 | Labels <span className="text-xs font-normal text-faint group-open:hidden">Edit</span> |
| 377 | </summary> |
| 378 | <Form method="post" className="mt-3 space-y-3" key={issue.labels.join()}> |
| 379 | <input type="hidden" name="action" value="labels" /> |
| 380 | <div className="flex flex-wrap gap-x-3 gap-y-2"> |
| 381 | {labels.map((name) => ( |
| 382 | <label key={name} className="flex cursor-pointer items-center gap-1.5"> |
| 383 | <input |
| 384 | type="checkbox" |
| 385 | name="label" |
| 386 | value={name} |
| 387 | defaultChecked={issue.labels.includes(name)} |
| 388 | className="accent-accent" |
| 389 | /> |
| 390 | <Label name={name} /> |
| 391 | </label> |
| 392 | ))} |
| 393 | </div> |
| 394 | <Input name="labels" placeholder="New labels, comma separated" /> |
| 395 | <Button variant="quiet" type="submit"> |
| 396 | Save labels |
| 397 | </Button> |
| 398 | </Form> |
| 399 | </details> |
| 400 | )} |
| 401 | |
| 402 | {canManage && ( |
| 403 | <Form method="post" className="flex flex-wrap gap-2 border-t border-line pt-5"> |
| 404 | {open ? ( |
| 405 | <> |
| 406 | <Button variant="quiet" type="submit" name="action" value="close-completed"> |
| 407 | Close issue |
| 408 | </Button> |
| 409 | <Button variant="quiet" type="submit" name="action" value="close-not-planned"> |
| 410 | Close as not planned |
| 411 | </Button> |
| 412 | </> |
| 413 | ) : ( |
| 414 | <Button variant="quiet" type="submit" name="action" value="reopen"> |
| 415 | Reopen issue |
| 416 | </Button> |
| 417 | )} |
| 418 | </Form> |
| 419 | )} |
| 420 | </aside> |
| 421 | </div> |
| 422 | ); |
| 423 | } |