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.

126 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { AtSign, Tag } from "lucide-react";
3import { Form } from "react-router";
4
5import { AGENT_HANDLE, mentionsClient } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings-agents";
8import { page } from "../../lib/meta";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
11import { Card } from "../../components/ui/card";
12import { work } from "../../lib/services.server";
13import { instrumented } from "../../lib/perf.server";
14import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
15import { requireCapability, requireInsider } from "../../lib/access.server";
16
17const mentions = mentionsClient(instrumented("work", env.WORK));
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Agent settings · ${params.owner}/${params.repo} · g1t` });
21}
22
23export async function loader({ params, context }: Route.LoaderArgs) {
24 const viewer = getViewer(context);
25 // Maintain and up; to anyone without a role here the page does not exist.
26 await requireInsider(context, params, "manage_settings");
27 const path = { namespace: params.owner, name: params.repo };
28 const [rules, labels] = await Promise.all([mentions.getAgentRules(path, viewer), work.listLabels(path, viewer)]);
29 return { rules: unwrap(rules), labels: labels.ok ? labels.value : [] };
30}
31
32export async function action({ request, params, context }: Route.ActionArgs) {
33 assertSameOrigin(request);
34 const user = requireUser(context, request);
35 await requireCapability(context, params, "manage_settings");
36 const form = await request.formData();
37 const path = { namespace: params.owner, name: params.repo };
38 const label = form.get("intent") === "off" ? null : String(form.get("label") ?? "").trim() || null;
39 const saved = await mentions.setAgentRules(user, path, { label });
40 if (!saved.ok) return { error: saved.error.message, notice: null };
41 return {
42 error: null,
43 notice: saved.value.label
44 ? `Issues labelled ${saved.value.label} now go to g1t.`
45 : "Labels no longer put g1t to work.",
46 };
47}
48
49export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
50 const { rules, labels } = loaderData;
51 const base = `/${params.owner}/${params.repo}`;
52 return (
53 <div className="max-w-4xl">
54 <RepoSettingsHeading base={base} />
55 <div className="min-h-6">
56 {actionData?.notice && <p className="text-sm text-success">{actionData.notice}</p>}
57 <ErrorText>{actionData?.error ?? null}</ErrorText>
58 </div>
59
60 <section className="grid gap-x-10 gap-y-4 lg:grid-cols-[16rem_1fr]">
61 <div>
62 <h2 className="flex items-center gap-2 font-medium">
63 <Tag size={15} className="text-muted" />
64 Label rule
65 </h2>
66 <p className="mt-1 text-sm text-muted">
67 When a member gives an issue this label, g1t takes it, as if they had assigned it. It starts as soon as
68 the project has room for another agent and nothing the issue depends on is still open.
69 </p>
70 </div>
71 <Card asChild className="space-y-3 p-4">
72 <Form method="post">
73 <Field label="Label" hint="An issue that already has it is not affected; adding it again is.">
74 {/* Keyed to the saved rule, so turning it off empties the box. */}
75 <Input key={rules.label ?? ""} name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} />
76 </Field>
77 <datalist id="known-labels">
78 {labels.map((label) => (
79 <option key={label.name} value={label.name} />
80 ))}
81 </datalist>
82 <div className="flex flex-wrap items-center gap-3">
83 <SubmitButton name="intent" value="save" pending="Saving…">
84 Save
85 </SubmitButton>
86 {rules.label && (
87 <SubmitButton variant="outline" name="intent" value="off" pending="Turning off…">
88 Turn off
89 </SubmitButton>
90 )}
91 {rules.updatedBy && rules.updatedAt && (
92 <span className="text-xs text-faint">
93 Changed by {rules.updatedBy} <TimeAgo at={rules.updatedAt} />
94 </span>
95 )}
96 </div>
97 </Form>
98 </Card>
99 </section>
100
101 <section className="mt-8 grid gap-x-10 gap-y-4 border-t border-line pt-8 lg:grid-cols-[16rem_1fr]">
102 <div>
103 <h2 className="flex items-center gap-2 font-medium">
104 <AtSign size={15} className="text-muted" />
105 Mentions
106 </h2>
107 <p className="mt-1 text-sm text-muted">Always on, for members of the workspace.</p>
108 </div>
109 <ul className="space-y-2 text-sm text-muted">
110 <li>
111 <span className="font-mono text-fg">{AGENT_HANDLE} take this</span> on an issue assigns it to g1t. A
112 question gets an answer in the thread instead.
113 </li>
114 <li>
115 On a pull request g1t made, a request sends it back to make the change. On any pull request,{" "}
116 <span className="font-mono text-fg">{AGENT_HANDLE} review</span> starts a review and a question is answered.
117 </li>
118 <li>
119 Mentions in code, in quotes or from people outside the workspace start nothing. Each comment starts one run at
120 most, and g1t replies saying what it did, or why it could not.
121 </li>
122 </ul>
123 </section>
124 </div>
125 );
126}