4.4 KB135 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { getCanopyRecentRuns } from "@/lib/grove-api";
4import { Badge } from "@/app/components/ui/badge";
5import { TimeAgo } from "@/app/components/time-ago";
6import {
7 formatTriggerType,
8 getInvocationRunSlug,
9 getInvocationStatus,
10 groupByInvocation,
11} from "@/lib/canopy-invocations";
12import { CanopyLiveRefresh } from "@/app/components/canopy-live-refresh";
13
14interface Props {
15 params: Promise<{ owner: string; repo: string }>;
16}
17
18export async function generateMetadata({ params }: Props): Promise<Metadata> {
19 const { repo } = await params;
20 return { title: `Canopy · ${repo}` };
21}
22
23interface Run {
24 id: number;
25 pipeline_name: string;
26 status: string;
27 trigger_type?: string | null;
28 commit_id: string | null;
29 commit_message: string | null;
30 trigger_ref?: string | null;
31 started_at?: string | null;
32 duration_ms?: number | null;
33 created_at: string;
34 repo_name: string;
35 owner_name: string;
36}
37
38const statusLabels: Record<string, string> = {
39 pending: "Pending",
40 running: "Running",
41 passed: "Passed",
42 failed: "Failed",
43 cancelled: "Cancelled",
44};
45
46async function getRepoRuns(owner: string, repo: string): Promise<Run[]> {
47 const data = await getCanopyRecentRuns<Run>({ owner, limit: 50 });
48 const runs = data?.runs ?? [];
49 return runs.filter((r) => r.repo_name === repo);
50}
51
52export default async function CanopyRepoPage({ params }: Props) {
53 const { owner, repo } = await params;
54 const runs = await getRepoRuns(owner, repo);
55
56 if (runs.length === 0) {
57 return (
58 <div className="max-w-3xl mx-auto px-4 py-6">
59 <p
60 className="text-sm py-8 text-center"
61 style={{ color: "var(--text-faint)" }}
62 >
63 No builds yet. Add a{" "}
64 <code className="text-xs">.canopy/*.yml</code> file to get started.
65 </p>
66 </div>
67 );
68 }
69
70 const invocations = groupByInvocation(runs);
71
72 return (
73 <div className="max-w-3xl mx-auto px-4 py-6">
74 <CanopyLiveRefresh scope="repo" owner={owner} repo={repo} />
75 <div>
76 {invocations.map((invocation) => {
77 const title =
78 invocation.commitMessage ||
79 (invocation.commitId
80 ? invocation.commitId.substring(0, 8)
81 : `${formatTriggerType(invocation.triggerType)} build`);
82
83 const summaryStatus = getInvocationStatus(invocation.runs);
84 const newestRun = invocation.runs[invocation.runs.length - 1];
85 const invocationHref = `/${owner}/${repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
86
87 return (
88 <Link
89 key={invocation.key}
90 href={invocationHref}
91 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
92 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
93 >
94 <span className="shrink-0 mt-0.5 sm:hidden">
95 <Badge variant={summaryStatus} compact>
96 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
97 </Badge>
98 </span>
99 <span className="shrink-0 mt-0.5 hidden sm:block">
100 <Badge
101 variant={summaryStatus}
102 style={{ minWidth: "4rem", textAlign: "center" }}
103 >
104 {statusLabels[summaryStatus] ?? summaryStatus}
105 </Badge>
106 </span>
107 <div className="min-w-0 flex-1">
108 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
109 {title}
110 </div>
111 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
112 <span>{formatTriggerType(invocation.triggerType)}</span>
113 {invocation.commitId && (
114 <>
115 <span>·</span>
116 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
117 </>
118 )}
119 <span>·</span>
120 <span>
121 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
122 </span>
123 <span className="ml-auto shrink-0">
124 <TimeAgo date={newestRun.created_at} />
125 </span>
126 </div>
127 </div>
128 </Link>
129 );
130 })}
131 </div>
132 </div>
133 );
134}
135