flagon-io/g1t

public

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

g1t/apps/web/app/routes/repo/issues.tsx

225 lines9,030 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react";
3import { Form, Link, useNavigation } from "react-router";
4
5import type { Route } from "./+types/issues";
6import { page } from "../../lib/meta";
7import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8import {
9 Assignee,
10 AssigneeStack,
11 IssueIcon,
12 Label,
13 StateTabs,
14} from "../../components/work";
15import { work } from "../../lib/services.server";
16import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
17
18/** As many sandboxes as one request may start. */
19const MAX_ASSIGNED_AT_ONCE = 10;
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` });
23}
24
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const viewer = getViewer(context);
27 const path = { namespace: params.owner, name: params.repo };
28 const query = new URL(request.url).searchParams;
29 const state = query.get("state") === "closed" ? "closed" : "open";
30 const label = query.get("label") ?? "";
31 const [issues, labels, agentsEnabled] = await Promise.all([
32 work.listIssues(path, viewer, { state, label: label || undefined }),
33 work.listLabels(path, viewer),
34 env.RUNNER.enabled(viewer, path),
35 ]);
36 return {
37 issues: unwrap(issues),
38 labels: unwrap(labels),
39 state,
40 label,
41 agentsEnabled,
42 } as const;
43}
44
45/** Assigns each selected issue to a g1t agent of its own. */
46export async function action({ request, params, context }: Route.ActionArgs) {
47 assertSameOrigin(request);
48 const user = requireUser(context, request);
49 const path = { namespace: params.owner, name: params.repo };
50 const numbers = (await request.formData())
51 .getAll("issue")
52 .map(Number)
53 .filter((number) => Number.isInteger(number) && number > 0);
54 if (numbers.length === 0) return { error: "Select the issues to assign first." };
55 if (numbers.length > MAX_ASSIGNED_AT_ONCE) {
56 return { error: `Assign at most ${MAX_ASSIGNED_AT_ONCE} issues at a time.` };
57 }
58 const results = await Promise.all(
59 numbers.map((number) => env.RUNNER.run(user, path, number)),
60 );
61 const refused = results.find((result) => !result.ok);
62 const started = results.filter((result) => result.ok).length;
63 return {
64 started,
65 error:
66 refused && !refused.ok
67 ? `${started} of ${numbers.length} assigned. ${refused.error.message}`
68 : null,
69 };
70}
71
72export default function Issues({ loaderData, actionData, params }: Route.ComponentProps) {
73 const { issues, labels, state, label, agentsEnabled } = loaderData;
74 const repo = `/${params.owner}/${params.repo}`;
75 const base = `${repo}/issues`;
76 const stateQuery = state === "closed" ? "state=closed" : "";
77 const assignable = agentsEnabled && state === "open" && issues.length > 0;
78 const assigning = useNavigation().state === "submitting";
79 return (
80 <div>
81 <StateTabs
82 to={base}
83 state={state}
84 query={label ? `label=${encodeURIComponent(label)}` : ""}
85 action={
86 <ButtonLink to={`${base}/new`}>
87 <Plus size={15} />
88 New issue
89 </ButtonLink>
90 }
91 />
92 <div className="mt-3 flex flex-wrap items-center gap-1.5">
93 {labels.map((name) => {
94 const active = name === label;
95 const query = [stateQuery, active ? "" : `label=${encodeURIComponent(name)}`]
96 .filter(Boolean)
97 .join("&");
98 return (
99 <Link
100 key={name}
101 to={`${base}?${query}`}
102 className={`flex items-center gap-1 rounded-full transition-opacity ${
103 label && !active ? "opacity-45 hover:opacity-100" : ""
104 }`}
105 >
106 <Label name={name} />
107 {active && <X size={12} className="text-muted" />}
108 </Link>
109 );
110 })}
111 </div>
112 <Form method="post" className="mt-4">
113 {assignable && (
114 <div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-2.5">
115 <Sparkles size={15} className="shrink-0 text-accent" />
116 <p className="min-w-0 grow text-sm text-muted">
117 Tick the issues to hand over. Each gets a g1t agent of its own,
118 and they all work at once.
119 </p>
120 <Button variant="accent" type="submit" disabled={assigning}>
121 {assigning ? "Starting sandboxes…" : "Assign to g1t agent"}
122 </Button>
123 </div>
124 )}
125 {actionData?.error ? (
126 <div className="mb-3">
127 <ErrorText>{actionData.error}</ErrorText>
128 </div>
129 ) : (
130 actionData?.started != null && (
131 <p className="mb-3 text-sm text-muted">
132 {actionData.started}{" "}
133 {actionData.started === 1 ? "agent is" : "agents are"} starting. Each
134 issue shows its pull request as it appears.
135 </p>
136 )
137 )}
138 {issues.length === 0 ? (
139 <EmptyState
140 title={
141 label
142 ? `No ${state} issues labelled ${label}`
143 : state === "open"
144 ? "No open issues"
145 : "No closed issues"
146 }
147 >
148 An issue says what should change: a bug, a feature, a question.
149 Agents and people open pull requests against it.
150 </EmptyState>
151 ) : (
152 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
153 {issues.map((issue) => (
154 <li key={issue.id} className="flex items-start transition-colors hover:bg-surface">
155 {assignable && (
156 <input
157 type="checkbox"
158 name="issue"
159 value={issue.number}
160 // Already being worked on: nothing more to hand over.
161 disabled={issue.agent != null || issue.queued}
162 aria-label={`Select issue #${issue.number}`}
163 className="mt-4 ml-4 accent-accent disabled:opacity-30"
164 />
165 )}
166 <Link
167 prefetch="intent"
168 to={`${base}/${issue.number}`}
169 className="flex min-w-0 grow items-start gap-3 px-4 py-3"
170 >
171 <span className="mt-0.5">
172 <IssueIcon issue={issue} />
173 </span>
174 <span className="min-w-0 grow">
175 <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
176 <span className="font-medium">{issue.title}</span>
177 {issue.labels.map((name) => (
178 <Label key={name} name={name} />
179 ))}
180 {issue.state === "open" && issue.agent && (
181 <Assignee agent={issue.agent} />
182 )}
183 {issue.state === "open" && issue.queued && !issue.agent && (
184 <span className="rounded-full border border-line px-2 py-px text-xs text-muted">
185 queued for g1t-agent
186 {issue.blockedBy.length > 0 &&
187 `, after ${issue.blockedBy.map((number) => `#${number}`).join(", ")}`}
188 </span>
189 )}
190 </span>
191 <span className="mt-0.5 block text-xs text-faint">
192 #{issue.number} opened <TimeAgo at={issue.createdAt} /> by{" "}
193 {issue.author.username}
194 {issue.resolvedBy != null && (
195 <span className="text-merged"> · resolved by #{issue.resolvedBy}</span>
196 )}
197 </span>
198 </span>
199 <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
200 <AssigneeStack people={issue.assignees} />
201 {issue.pullCount > 0 && (
202 <span
203 className="flex items-center gap-1"
204 title={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}
205 >
206 <GitPullRequest size={13} />
207 {issue.pullCount}
208 </span>
209 )}
210 {issue.commentCount > 0 && (
211 <span className="flex items-center gap-1">
212 <MessageSquare size={13} />
213 {issue.commentCount}
214 </span>
215 )}
216 </span>
217 </Link>
218 </li>
219 ))}
220 </ul>
221 )}
222 </Form>
223 </div>
224 );
225}