Skip to content

g1t/apps/web/app/routes/repo/check-run.tsx

252 lines12,166 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.