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.

g1t.sh
218 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { Bot, CircleDot, ExternalLink } from "lucide-react";
3import { Form, Link, redirect } from "react-router";
4
5import type { ErrorGroup } from "@g1t/contracts";
6
7import type { Route } from "./+types/observability-errors";
8import { FilterBar, ObservabilityHeader, useLoadingView } from "../../components/observability";
9import { EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
10import { Badge } from "../../components/ui/badge";
11import { Card } from "../../components/ui/card";
12import { Checkbox } from "../../components/ui/checkbox";
13import { refusal } from "../../lib/access.server";
14import { cn } from "../../lib/cn";
15import { issuePath, notStarted } from "../../lib/delegate";
16import { page } from "../../lib/meta";
17import { count, errorIssue, viewHref } from "../../lib/observability";
18import { observed, soft } from "../../lib/observability.server";
19import { work } from "../../lib/services.server";
20import { assertSameOrigin, requireUser } from "../../lib/session.server";
21
22const STATES: ErrorGroup["state"][] = ["open", "resolved", "ignored"];
23
24export function meta({ params, ...args }: Route.MetaArgs) {
25 return page(args, { title: `Errors · ${params.owner}/${params.repo} · g1t` });
26}
27
28export async function loader({ params, context, request }: Route.LoaderArgs) {
29 const { projectId, service, view, filter, access } = await observed(context, params, request);
30 const stateParam = new URL(request.url).searchParams.get("state");
31 const state = STATES.find((s) => s === stateParam) ?? "open";
32 const [groups, facets] = await Promise.all([
33 soft(service.errors({ project_id: projectId, ...filter, deployment_id: null, state }), "errors"),
34 soft(service.facets({ project_id: projectId }), "facets"),
35 ]);
36 return { view, groups, facets, state, can: { triage: access.can.triage, issue: access.can.participate, run: access.can.run } };
37}
38
39export async function action({ request, params, context }: Route.ActionArgs) {
40 assertSameOrigin(request);
41 const user = requireUser(context, request);
42 const { projectId, service, repo } = await observed(context, params, request);
43 const form = await request.formData();
44 const intent = String(form.get("intent") ?? "");
45 const fingerprint = String(form.get("fingerprint") ?? "");
46 if (intent === "resolved" || intent === "ignored" || intent === "open") {
47 const refused = await refusal(context, params, "triage");
48 if (refused) return { error: refused, fingerprint };
49 await service.setError({ project_id: projectId, fingerprint, state: intent });
50 return { notice: intent === "open" ? "Open again." : intent === "resolved" ? "Resolved. It opens again if it happens again." : "Ignored." };
51 }
52 if (intent === "issue") {
53 const agent = form.get("agent") === "on";
54 const refused = await refusal(context, params, agent ? "run" : "participate");
55 if (refused) return { error: refused, fingerprint };
56 const group = (await service.errors({ project_id: projectId })).find((g) => g.fingerprint === fingerprint);
57 if (!group) return { error: "That error is no longer kept.", fingerprint };
58 const url = new URL(request.url);
59 const { title, body } = errorIssue(group, `${url.origin}/${params.owner}/${params.repo}/observability`);
60 const path = { namespace: repo.namespace, name: repo.name };
61 // With g1t on it, it opens and the agent starts in one step, as a new issue's "Assign g1t" does.
62 if (agent) {
63 const delegated = await env.RUNNER.delegate(user, path, { title, body, labels: ["bug"] });
64 if (!delegated.ok) return { error: delegated.error.message, fingerprint };
65 await service.setError({ project_id: projectId, fingerprint, issue_number: delegated.value.issue.number });
66 const refusedStart = notStarted(delegated.value.agent, path, delegated.value.issue.number);
67 if (refusedStart) return { error: refusedStart.message, fingerprint };
68 throw redirect(issuePath(path, delegated.value.issue.number));
69 }
70 const opened = await work.openIssue(user, path, { title, body, labels: ["bug"] });
71 if (!opened.ok) return { error: opened.error.message, fingerprint };
72 await service.setError({ project_id: projectId, fingerprint, issue_number: opened.value.number });
73 throw redirect(issuePath(path, opened.value.number));
74 }
75 return { error: "Unknown request.", fingerprint };
76}
77
78export default function Errors({ loaderData, actionData, params }: Route.ComponentProps) {
79 const { view, groups, facets, state, can } = loaderData;
80 const base = `/${params.owner}/${params.repo}`;
81 const path = `${base}/observability/errors`;
82 const loading = useLoadingView();
83 const failedOn = actionData && "fingerprint" in actionData ? actionData.fingerprint : null;
84
85 return (
86 <div className="mx-auto max-w-6xl">
87 <ObservabilityHeader
88 title="Errors"
89 about="Exceptions, grouped: the same error thrown a thousand times is one row. Each can become an issue, with g1t on it if you like, carrying the stack and the request it came from."
90 />
91 <FilterBar path={path} view={view} environments={facets?.environments ?? []} services={facets?.services ?? []}>
92 <nav className="flex rounded-md border border-line p-0.5 text-sm" aria-label="State">
93 {STATES.map((s) => (
94 <Link
95 key={s}
96 to={viewHref(path, view, { state: s === "open" ? null : s })}
97 preventScrollReset
98 aria-current={s === state ? "true" : undefined}
99 className={cn("rounded px-2 py-0.5 text-xs capitalize", s === state ? "bg-raised text-fg" : "text-muted hover:text-fg")}
100 >
101 {s}
102 </Link>
103 ))}
104 </nav>
105 </FilterBar>
106 <div className="mt-3 min-h-5">
107 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
108 </div>
109
110 <div className={cn("mt-2 transition-opacity", loading && "opacity-60")}>
111 {!groups ? (
112 <Card className="p-6 text-sm text-muted">Errors could not be loaded just now. Reload the page to try again.</Card>
113 ) : groups.length === 0 ? (
114 <EmptyState title={state === "open" ? "No open errors in this period" : `No ${state} errors in this period`}>
115 Exceptions thrown by apps on g1t.page show here on their own; others arrive as OpenTelemetry exception events and log records.
116 </EmptyState>
117 ) : (
118 <ul className="space-y-3">
119 {groups.map((g) => (
120 <li key={g.fingerprint} id={g.fingerprint}>
121 <Card className="min-w-0 p-4">
122 <div className="flex flex-wrap items-start justify-between gap-3">
123 <div className="min-w-0 flex-1">
124 <p className="flex flex-wrap items-center gap-2">
125 <span className="font-mono text-sm text-danger">{g.name}</span>
126 <Badge tone="neutral" className="font-mono">
127 {g.service || "app"}
128 </Badge>
129 {g.environment && <Badge tone="neutral">{g.environment}</Badge>}
130 {g.issue_number && (
131 <Link to={`${base}/issues/${g.issue_number}`} className="inline-flex items-center gap-1 text-xs text-accent hover:underline">
132 <CircleDot size={12} />#{g.issue_number}
133 </Link>
134 )}
135 </p>
136 <p className="mt-1 text-sm break-words">{g.message}</p>
137 <p className="mt-1.5 text-xs text-faint tabular-nums">
138 <span className="text-fg">{count(g.count)}</span> time{g.count === 1 ? "" : "s"} · first <TimeAgo at={g.first_us / 1000} /> · last{" "}
139 <TimeAgo at={g.last_us / 1000} />
140 {g.first_deployment_id && (
141 <>
142 {" "}
143 · since{" "}
144 <Link to={`${base}/deployments/${g.first_deployment_id}`} className="font-mono hover:text-fg">
145 {g.first_deployment_id.slice(0, 12)}
146 </Link>
147 </>
148 )}
149 </p>
150 </div>
151 <div className="flex shrink-0 flex-wrap gap-2">
152 {g.last_request_id && (
153 <Link to={`${base}/observability/requests/${g.last_request_id}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
154 Latest request <ExternalLink size={11} />
155 </Link>
156 )}
157 {g.last_trace_id && (
158 <Link to={`${base}/observability/traces/${g.last_trace_id}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
159 Trace <ExternalLink size={11} />
160 </Link>
161 )}
162 </div>
163 </div>
164 {g.stack && (
165 <details className="mt-3">
166 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Stack</summary>
167 <pre className="mt-2 max-h-72 overflow-auto rounded-md border border-line bg-bg p-3 font-mono text-xs whitespace-pre">{g.stack}</pre>
168 </details>
169 )}
170 {(can.triage || (can.issue && !g.issue_number)) && (
171 <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3">
172 {can.issue && !g.issue_number && (
173 <Form method="post" className="flex flex-wrap items-center gap-2">
174 <input type="hidden" name="fingerprint" value={g.fingerprint} />
175 <SubmitButton name="intent" value="issue" size="sm" variant="outline">
176 Make an issue
177 </SubmitButton>
178 {can.run && (
179 <label className="flex items-center gap-1.5 text-xs text-muted">
180 <Checkbox name="agent" defaultChecked />
181 <Bot size={13} />
182 Assign g1t
183 </label>
184 )}
185 </Form>
186 )}
187 {can.triage && (
188 <Form method="post" className="ml-auto flex gap-2">
189 <input type="hidden" name="fingerprint" value={g.fingerprint} />
190 {g.state !== "resolved" && (
191 <SubmitButton name="intent" value="resolved" size="sm" variant="ghost">
192 Resolve
193 </SubmitButton>
194 )}
195 {g.state !== "ignored" && (
196 <SubmitButton name="intent" value="ignored" size="sm" variant="ghost">
197 Ignore
198 </SubmitButton>
199 )}
200 {g.state !== "open" && (
201 <SubmitButton name="intent" value="open" size="sm" variant="ghost">
202 Open again
203 </SubmitButton>
204 )}
205 </Form>
206 )}
207 </div>
208 )}
209 {failedOn === g.fingerprint && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>}
210 </Card>
211 </li>
212 ))}
213 </ul>
214 )}
215 </div>
216 </div>
217 );
218}