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.

277 linesCodeBlameRaw
1import { AlertTriangle, ArrowUpRight, FileCode2, GitCommitHorizontal, Info, RotateCw, XCircle } from "lucide-react";
2import type { ReactNode } from "react";
3import { Form, Link, redirect } from "react-router";
4
5import type { AnnotationLevel, CheckAnnotation, CommitCheckRun } from "@g1t/contracts";
6
7import type { Route } from "./+types/check-run";
8import { Boundary, Unavailable } from "../../components/boundary";
9import { CheckStateIcon } from "../../components/commit-checks";
10import { Markdown } from "../../components/markdown";
11import { EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
12import { Card } from "../../components/ui/card";
13import { Hint } from "../../components/ui/hint";
14import { accessTo, refusal } from "../../lib/access.server";
15import { checkDetail, took } from "../../lib/commit-checks";
16import { formatLineHash } from "../../lib/line-anchor";
17import { page } from "../../lib/meta";
18import { isFailed, part } from "../../lib/partial";
19import { work } from "../../lib/services.server";
20import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
21
22export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
23 const name = loaderData?.run.name;
24 return page(args, { title: `${name ? `${name} · ` : ""}Checks · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 const path = { namespace: params.owner, name: params.repo };
30 const [run, annotations, access] = await Promise.all([
31 work.getCheckRun(path, params.id, viewer),
32 // The check run is what the page is about; its annotations are a part.
33 part(work.checkRunAnnotations(path, params.id, viewer), "Annotations"),
34 accessTo(context, params),
35 ]);
36 const found = unwrap(run);
37 // A g1t Actions job is shown where its run is, with its log.
38 if (found.workflow) throw redirect(found.htmlUrl);
39 return {
40 run: found,
41 annotations: isFailed(annotations) ? annotations : annotations.ok ? annotations.value : [],
42 // Pressing its buttons and asking for it again: Write and up.
43 canReport: access.can.push,
44 };
45}
46
47export async function action({ request, params, context }: Route.ActionArgs) {
48 assertSameOrigin(request);
49 const user = requireUser(context, request);
50 const repo = { namespace: params.owner, name: params.repo };
51 const form = await request.formData();
52 const refused = await refusal(context, params, "push");
53 if (refused) return { error: refused };
54 const intent = String(form.get("intent"));
55 const done =
56 intent === "rerequest"
57 ? await work.rerequestCheckRun(user, repo, params.id)
58 : await work.requestCheckAction(user, repo, params.id, String(form.get("identifier") ?? ""));
59 if (!done.ok) return { error: done.error.message };
60 return { notice: intent === "rerequest" ? "Asked for it to run again." : "Sent. The reporter takes it from here." };
61}
62
63const LEVEL: Record<AnnotationLevel, { icon: ReactNode; word: string }> = {
64 failure: { icon: <XCircle size={15} className="mt-0.5 shrink-0 text-danger" />, word: "Failure" },
65 warning: { icon: <AlertTriangle size={15} className="mt-0.5 shrink-0 text-warn" />, word: "Warning" },
66 notice: { icon: <Info size={15} className="mt-0.5 shrink-0 text-muted" />, word: "Notice" },
67};
68
69/** Annotations by file, in the order the files first came up. */
70function byFile(annotations: CheckAnnotation[]): [string, CheckAnnotation[]][] {
71 const files = new Map<string, CheckAnnotation[]>();
72 for (const annotation of annotations) files.set(annotation.path, [...(files.get(annotation.path) ?? []), annotation]);
73 return [...files];
74}
75
76function lines(annotation: CheckAnnotation): string {
77 return annotation.startLine === annotation.endLine ? `Line ${annotation.startLine}` : `Lines ${annotation.startLine}–${annotation.endLine}`;
78}
79
80function encodePath(path: string): string {
81 return path.split("/").map(encodeURIComponent).join("/");
82}
83
84function Annotations({ run, annotations, base }: { run: CommitCheckRun; annotations: CheckAnnotation[]; base: string }) {
85 const counts = (["failure", "warning", "notice"] as const)
86 .map((level) => [level, annotations.filter((annotation) => annotation.annotationLevel === level).length] as const)
87 .filter(([, count]) => count > 0);
88 return (
89 <section className="space-y-3">
90 <h3 className="flex flex-wrap items-baseline gap-x-3 text-base font-semibold">
91 Annotations
92 <span className="text-sm font-normal text-muted">
93 {counts.map(([level, count]) => `${count} ${LEVEL[level].word.toLowerCase()}${count === 1 ? "" : "s"}`).join(", ")}
94 </span>
95 </h3>
96 {byFile(annotations).map(([path, list]) => (
97 <Card key={path} tone="plain" className="overflow-hidden">
98 <p className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm">
99 <FileCode2 size={15} className="shrink-0 text-faint" />
100 <Link
101 to={`${base}/blob/${run.headSha}/${encodePath(path)}`}
102 className="min-w-0 truncate font-mono text-[0.8125rem] hover:text-accent hover:underline"
103 >
104 {path}
105 </Link>
106 </p>
107 <ul className="divide-y divide-line">
108 {list.map((annotation, index) => (
109 <li key={`${annotation.startLine}:${index}`} className="flex gap-3 px-4 py-3 text-sm">
110 {LEVEL[annotation.annotationLevel].icon}
111 <div className="min-w-0 grow">
112 <p className="flex flex-wrap items-baseline gap-x-2">
113 {annotation.title && <span className="font-medium">{annotation.title}</span>}
114 {annotation.startLine > 0 && (
115 <Link
116 to={`${base}/blob/${run.headSha}/${encodePath(path)}${formatLineHash({ start: annotation.startLine, end: annotation.endLine })}`}
117 className="font-mono text-xs text-muted hover:text-accent hover:underline"
118 >
119 {lines(annotation)}
120 </Link>
121 )}
122 </p>
123 <p className="mt-0.5 whitespace-pre-wrap text-fg-soft">{annotation.message}</p>
124 {annotation.rawDetails && (
125 <details className="mt-1.5">
126 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Details</summary>
127 <pre className="mt-1.5 overflow-x-auto rounded-md bg-bg px-3 py-2 font-mono text-xs text-muted">{annotation.rawDetails}</pre>
128 </details>
129 )}
130 </div>
131 </li>
132 ))}
133 </ul>
134 </Card>
135 ))}
136 </section>
137 );
138}
139
140export default function CheckRunPage({ loaderData, actionData, params }: Route.ComponentProps) {
141 const { run, annotations, canReport } = loaderData;
142 const base = `/${params.owner}/${params.repo}`;
143 const state = run.status !== "completed" ? "pending" : run.conclusion === "success" ? "success" : (run.conclusion ?? "neutral");
144 const shownState = state === "timed_out" || state === "action_required" || state === "failure" ? "failure" : state;
145 const detail = checkDetail({
146 kind: "check_run",
147 id: run.id,
148 name: run.name,
149 state: shownState,
150 description: null,
151 detailsUrl: run.detailsUrl,
152 url: null,
153 app: run.app.name,
154 startedAt: run.startedAt,
155 completedAt: run.completedAt,
156 });
157 const { title, summary, text } = run.output;
158 return (
159 <div className="space-y-6">
160 <Card asChild className="overflow-hidden">
161 <section>
162 <div className="flex flex-wrap items-start gap-x-4 gap-y-3 p-4 sm:p-5">
163 <span className="mt-1">
164 <CheckStateIcon state={shownState} size={22} />
165 </span>
166 <div className="min-w-0 grow basis-64">
167 <p className="text-xs text-faint">
168 <Link to={`${base}/commit/${run.headSha}`} className="hover:text-fg">
169 Checks
170 </Link>{" "}
171 · {run.app.name}
172 </p>
173 <h2 className="mt-1 text-xl font-semibold tracking-tight wrap-break-word">{run.name}</h2>
174 <p className="mt-1 text-sm text-muted">
175 {detail}
176 {run.conclusion && run.conclusion !== "success" && run.conclusion !== "failure" && (
177 <span className="text-faint"> · {run.conclusion.replace("_", " ")}</span>
178 )}
179 </p>
180 </div>
181 <div className="flex flex-wrap items-center gap-2">
182 {run.detailsUrl && (
183 <a
184 href={run.detailsUrl}
185 target="_blank"
186 rel="noreferrer"
187 className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:text-fg"
188 >
189 View on {run.app.name}
190 <ArrowUpRight size={14} />
191 </a>
192 )}
193 {canReport && (
194 <Form method="post">
195 <Hint label={`Ask ${run.app.name} to run it again`}>
196 <SubmitButton variant="outline" size="sm" name="intent" value="rerequest" pending="Asking…">
197 <RotateCw size={14} />
198 Re-run
199 </SubmitButton>
200 </Hint>
201 </Form>
202 )}
203 </div>
204 </div>
205 <div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-3 text-sm text-muted sm:px-5">
206 <span className="flex items-center gap-1.5">
207 <GitCommitHorizontal size={14} className="text-faint" />
208 <Link to={`${base}/commit/${run.headSha}`} className="font-mono text-xs text-fg/80 hover:text-accent hover:underline">
209 {run.headSha.slice(0, 7)}
210 </Link>
211 </span>
212 {run.startedAt && (
213 <span>
214 Started <TimeAgo at={run.startedAt} />
215 </span>
216 )}
217 {run.completedAt && took(run.startedAt, run.completedAt) && <span>Took {took(run.startedAt, run.completedAt)}</span>}
218 {run.externalId && <span className="min-w-0 font-mono text-xs break-all text-faint">{run.externalId}</span>}
219 </div>
220 {run.actions.length > 0 && canReport && (
221 <div className="flex flex-wrap items-center gap-2 border-t border-line px-5 py-3">
222 {run.actions.map((action) => (
223 <Form method="post" key={action.identifier}>
224 <input type="hidden" name="intent" value="action" />
225 <Hint label={action.description}>
226 <SubmitButton variant="outline" size="sm" name="identifier" value={action.identifier} pending="Sending…">
227 {action.label}
228 </SubmitButton>
229 </Hint>
230 </Form>
231 ))}
232 </div>
233 )}
234 {actionData && (
235 <div className="border-t border-line px-5 py-2.5">
236 {"error" in actionData ? <ErrorText>{actionData.error}</ErrorText> : <p className="text-sm text-success">{actionData.notice}</p>}
237 </div>
238 )}
239 </section>
240 </Card>
241
242 {title || summary || text ? (
243 // The report is whatever the app wrote: one that will not draw leaves the run.
244 <Boundary what="The report">
245 <Card asChild tone="plain" className="p-4 sm:p-6">
246 <section>
247 {title && <h3 className="mb-3 text-lg font-semibold">{title}</h3>}
248 {summary && <Markdown source={summary} repo={{ namespace: params.owner, name: params.repo }} />}
249 {text && (
250 <div className={summary ? "mt-6 border-t border-line pt-6" : ""}>
251 <Markdown source={text} repo={{ namespace: params.owner, name: params.repo }} />
252 </div>
253 )}
254 </section>
255 </Card>
256 </Boundary>
257 ) : (
258 <EmptyState title={run.status === "completed" ? "No report" : "No report yet"}>
259 {run.app.name} {run.status === "completed" ? "said nothing more about this run." : "has not said anything more yet."}
260 </EmptyState>
261 )}
262
263 {isFailed(annotations) ? (
264 <section className="space-y-3">
265 <h3 className="text-base font-semibold">Annotations</h3>
266 <Unavailable what="Annotations" />
267 </section>
268 ) : (
269 annotations.length > 0 && (
270 <Boundary what="Annotations">
271 <Annotations run={run} annotations={annotations} base={base} />
272 </Boundary>
273 )
274 )}
275 </div>
276 );
277}