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.

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