| @@ -89,13 +89,14 @@ | ||
| 89 | 89 | .prepare(`SELECT * FROM repos_with_owner ORDER BY updated_at DESC`) |
| 90 | 90 | .all() as any[]; |
| 91 | 91 | |
| 92 | // Filter out private repos the user can't access | |
| 93 | const repos = allRepos.filter( | |
| 94 | (r) => !r.is_private || (userId != null && ( | |
| 95 | (r.owner_type === "user" && r.owner_id === userId) || | |
| 96 | r.owner_type === "org" // org membership checked lazily; show to any authed user for now | |
| 97 | )) | |
| 92 | // Filter out private repos the user can't access. Org membership is | |
| 93 | // resolved via canAccessRepo (hub API roundtrip per private org repo), | |
| 94 | // not lazily — listing private repo names to non-members leaks | |
| 95 | // existence and is a real auth bug. | |
| 96 | const accessChecks = await Promise.all( | |
| 97 | allRepos.map(async (r) => ({ r, ok: await canAccessRepo(r, userId) })), | |
| 98 | 98 | ); |
| 99 | const repos = accessChecks.filter((x) => x.ok).map((x) => x.r); | |
| 99 | 100 | |
| 100 | 101 | const reposWithActivity = await Promise.all( |
| 101 | 102 | repos.map(async (repo) => { |
| 102 | 103 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | import { groveApiUrl, timeAgo } from "@/lib/utils"; | |
| 3 | import { timeAgo } from "@/lib/utils"; | |
| 4 | import { getRepoCommits } from "@/lib/grove-api"; | |
| 4 | 5 | |
| 5 | 6 | interface Props { |
| 6 | 7 | params: Promise<{ owner: string; repo: string }>; |
| @@ -12,21 +13,13 @@ | ||
| 12 | 13 | return { title: `Commits · ${repo}` }; |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | async function getCommits(owner: string, repo: string, ref: string) { | |
| 16 | const res = await fetch( | |
| 17 | `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${ref}?limit=50`, | |
| 18 | { cache: "no-store" } | |
| 19 | ); | |
| 20 | if (!res.ok) return null; | |
| 21 | return res.json(); | |
| 22 | } | |
| 23 | 16 | |
| 24 | 17 | export default async function CommitsPage({ params, searchParams }: Props) { |
| 25 | 18 | const { owner, repo } = await params; |
| 26 | 19 | const { ref: refParam } = await searchParams; |
| 27 | 20 | const ref = refParam ?? "main"; |
| 28 | 21 | |
| 29 | const data = await getCommits(owner, repo, ref); | |
| 22 | const data = await getRepoCommits(owner, repo, ref, { limit: 50 }); | |
| 30 | 23 | |
| 31 | 24 | if (!data) { |
| 32 | 25 | return ( |
| 33 | 26 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | import { timeAgoFromDate, groveApiUrl } from "@/lib/utils"; | |
| 3 | import { timeAgoFromDate } from "@/lib/utils"; | |
| 4 | import { getRepoDiffs } from "@/lib/grove-api"; | |
| 4 | 5 | |
| 5 | 6 | interface Props { |
| 6 | 7 | params: Promise<{ owner: string; repo: string }>; |
| @@ -12,18 +13,6 @@ | ||
| 12 | 13 | return { title: `Diffs · ${repo}` }; |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | async function getDiffs(owner: string, repo: string, status: string) { | |
| 16 | try { | |
| 17 | const res = await fetch( | |
| 18 | `${groveApiUrl}/api/repos/${owner}/${repo}/diffs?status=${status}`, | |
| 19 | { cache: "no-store" } | |
| 20 | ); | |
| 21 | if (!res.ok) return null; | |
| 22 | return res.json(); | |
| 23 | } catch { | |
| 24 | return null; | |
| 25 | } | |
| 26 | } | |
| 27 | 16 | |
| 28 | 17 | const statusStyles: Record<string, { bg: string; text: string; border: string }> = { |
| 29 | 18 | open: { bg: "var(--status-open-bg)", text: "var(--status-open-text)", border: "var(--status-open-border)" }, |
| @@ -39,7 +28,7 @@ | ||
| 39 | 28 | const { status: statusParam } = await searchParams; |
| 40 | 29 | const status = statusParam ?? "open"; |
| 41 | 30 | |
| 42 | const data = await getDiffs(owner, repo, status); | |
| 31 | const data = await getRepoDiffs(owner, repo, status); | |
| 43 | 32 | |
| 44 | 33 | return ( |
| 45 | 34 | <> |
| 46 | 35 | |
| @@ -3,7 +3,13 @@ | ||
| 3 | 3 | import { FileIcon } from "@/app/components/file-icon"; |
| 4 | 4 | import { Markdown } from "@/app/components/markdown"; |
| 5 | 5 | import { GitImportForm } from "@/app/components/git-import-form"; |
| 6 | import { groveApiUrl, encodePath, timeAgo } from "@/lib/utils"; | |
| 6 | import { encodePath, timeAgo } from "@/lib/utils"; | |
| 7 | import { | |
| 8 | getRepoBranches, | |
| 9 | getRepoTree, | |
| 10 | getRepoBlob, | |
| 11 | getRepoCommits, | |
| 12 | } from "@/lib/grove-api"; | |
| 7 | 13 | |
| 8 | 14 | interface Props { |
| 9 | 15 | params: Promise<{ owner: string; repo: string }>; |
| @@ -14,46 +20,13 @@ | ||
| 14 | 20 | return { title: `Grove · ${repo}` }; |
| 15 | 21 | } |
| 16 | 22 | |
| 17 | async function getTree(owner: string, repo: string, ref: string) { | |
| 18 | const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}`, { | |
| 19 | cache: "no-store", | |
| 20 | }); | |
| 21 | if (!res.ok) return null; | |
| 22 | return res.json(); | |
| 23 | } | |
| 24 | ||
| 25 | async function getBlob(owner: string, repo: string, ref: string, path: string) { | |
| 26 | const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/blob/${ref}/${path}`, { | |
| 27 | cache: "no-store", | |
| 28 | }); | |
| 29 | if (!res.ok) return null; | |
| 30 | return res.json(); | |
| 31 | } | |
| 32 | ||
| 33 | async function getBookmarks(owner: string, repo: string) { | |
| 34 | const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/branches`, { | |
| 35 | cache: "no-store", | |
| 36 | }); | |
| 37 | if (!res.ok) return null; | |
| 38 | return res.json(); | |
| 39 | } | |
| 40 | ||
| 41 | async function getLatestCommit(owner: string, repo: string, ref: string) { | |
| 42 | const res = await fetch( | |
| 43 | `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${ref}?limit=1`, | |
| 44 | { cache: "no-store" } | |
| 45 | ); | |
| 46 | if (!res.ok) return null; | |
| 47 | return res.json(); | |
| 48 | } | |
| 49 | ||
| 50 | 23 | async function findReadme(owner: string, repo: string, ref: string, entries: any[]) { |
| 51 | 24 | const readmeNames = ["README.md", "README", "readme.md", "README.txt"]; |
| 52 | 25 | const readmeEntry = entries.find( |
| 53 | 26 | (e: any) => e.type !== "tree" && readmeNames.includes(e.name) |
| 54 | 27 | ); |
| 55 | 28 | if (!readmeEntry) return null; |
| 56 | const blob = await getBlob(owner, repo, ref, readmeEntry.name); | |
| 29 | const blob = await getRepoBlob(owner, repo, ref, readmeEntry.name); | |
| 57 | 30 | return blob?.content ?? null; |
| 58 | 31 | } |
| 59 | 32 | |
| @@ -67,7 +40,7 @@ | ||
| 67 | 40 | export default async function RepoPage({ params }: Props) { |
| 68 | 41 | const { owner, repo } = await params; |
| 69 | 42 | |
| 70 | const bookmarks = await getBookmarks(owner, repo); | |
| 43 | const bookmarks = await getRepoBranches(owner, repo); | |
| 71 | 44 | if (!bookmarks) { |
| 72 | 45 | return ( |
| 73 | 46 | <div className="py-10"> |
| @@ -85,8 +58,8 @@ | ||
| 85 | 58 | const mainBookmark = branches.find((b: any) => b.name === "main"); |
| 86 | 59 | const ref = mainBookmark?.name ?? branches[0]?.name ?? "main"; |
| 87 | 60 | const [tree, latestCommitData] = await Promise.all([ |
| 88 | getTree(owner, repo, ref), | |
| 89 | getLatestCommit(owner, repo, ref), | |
| 61 | getRepoTree(owner, repo, ref), | |
| 62 | getRepoCommits(owner, repo, ref, { limit: 1 }), | |
| 90 | 63 | ]); |
| 91 | 64 | const readme = tree ? await findReadme(owner, repo, ref, tree.entries) : null; |
| 92 | 65 | const isMarkdown = readme !== null; |
| 93 | 66 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | import { groveApiUrl, timeAgo, encodePath } from "@/lib/utils"; | |
| 3 | import { timeAgo, encodePath } from "@/lib/utils"; | |
| 4 | import { getRepoBlame } from "@/lib/grove-api"; | |
| 4 | 5 | |
| 5 | 6 | interface Props { |
| 6 | 7 | params: Promise<{ owner: string; repo: string; path: string[] }>; |
| @@ -13,21 +14,20 @@ | ||
| 13 | 14 | return { title: `Blame · ${fileName} · ${repo}` }; |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | async function getBlame(owner: string, repo: string, ref: string, path: string) { | |
| 17 | const res = await fetch( | |
| 18 | `${groveApiUrl}/api/repos/${owner}/${repo}/blame/${ref}/${path}`, | |
| 19 | { cache: "no-store" } | |
| 20 | ); | |
| 21 | if (!res.ok) return null; | |
| 22 | return res.json(); | |
| 23 | } | |
| 24 | 17 | |
| 25 | 18 | export default async function BlamePage({ params }: Props) { |
| 26 | 19 | const { owner, repo, path: pathParts } = await params; |
| 27 | 20 | const ref = pathParts[0] ?? "main"; |
| 28 | 21 | const path = pathParts.slice(1).join("/"); |
| 29 | 22 | |
| 30 | const data = await getBlame(owner, repo, ref, path); | |
| 23 | type BlameLine = { | |
| 24 | hash: string; | |
| 25 | author?: string; | |
| 26 | timestamp?: number; | |
| 27 | line_no: number; | |
| 28 | content: string; | |
| 29 | }; | |
| 30 | const data = await getRepoBlame<{ blame: BlameLine[] }>(owner, repo, ref, path); | |
| 31 | 31 | |
| 32 | 32 | if (!data) { |
| 33 | 33 | return ( |
| 34 | 34 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | 3 | import { createHighlighter, type Highlighter } from "shiki"; |
| 4 | import { groveApiUrl, formatSize, encodePath } from "@/lib/utils"; | |
| 4 | import { formatSize, encodePath } from "@/lib/utils"; | |
| 5 | import { getRepoBlob } from "@/lib/grove-api"; | |
| 5 | 6 | |
| 6 | 7 | let highlighter: Highlighter | null = null; |
| 7 | 8 | |
| @@ -31,14 +32,6 @@ | ||
| 31 | 32 | return { title: `${fileName} · ${repo}` }; |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | async function getBlob(owner: string, repo: string, ref: string, path: string) { | |
| 35 | const res = await fetch( | |
| 36 | `${groveApiUrl}/api/repos/${owner}/${repo}/blob/${ref}/${path}`, | |
| 37 | { cache: "no-store" } | |
| 38 | ); | |
| 39 | if (!res.ok) return null; | |
| 40 | return res.json(); | |
| 41 | } | |
| 42 | 35 | |
| 43 | 36 | function getLang(filename: string): string { |
| 44 | 37 | const ext = filename.split(".").pop()?.toLowerCase() ?? ""; |
| @@ -92,7 +85,7 @@ | ||
| 92 | 85 | const path = pathParts.slice(1).join("/"); |
| 93 | 86 | const filename = pathParts[pathParts.length - 1]; |
| 94 | 87 | |
| 95 | const blob = await getBlob(owner, repo, ref, path); | |
| 88 | const blob = await getRepoBlob(owner, repo, ref, path); | |
| 96 | 89 | |
| 97 | 90 | if (!blob) { |
| 98 | 91 | return ( |
| 99 | 92 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | 3 | import { createHighlighter, type Highlighter } from "shiki"; |
| 4 | import { groveApiUrl, timeAgo } from "@/lib/utils"; | |
| 4 | import { timeAgo } from "@/lib/utils"; | |
| 5 | import { getRepoCommits, getRepoDiff } from "@/lib/grove-api"; | |
| 5 | 6 | import { DiffViewer } from "./diff-viewer"; |
| 6 | 7 | |
| 7 | 8 | let highlighter: Highlighter | null = null; |
| @@ -98,31 +99,8 @@ | ||
| 98 | 99 | } |
| 99 | 100 | |
| 100 | 101 | async function getCommit(owner: string, repo: string, sha: string) { |
| 101 | try { | |
| 102 | const res = await fetch( | |
| 103 | `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${sha}?limit=1`, | |
| 104 | { cache: "no-store" } | |
| 105 | ); | |
| 106 | if (!res.ok) return null; | |
| 107 | const data = await res.json(); | |
| 108 | return data.commits?.[0] ?? null; | |
| 109 | } catch { | |
| 110 | return null; | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | ||
| 115 | async function getDiff(owner: string, repo: string, base: string, head: string) { | |
| 116 | try { | |
| 117 | const res = await fetch( | |
| 118 | `${groveApiUrl}/api/repos/${owner}/${repo}/diff?base=${base}&head=${head}`, | |
| 119 | { cache: "no-store" } | |
| 120 | ); | |
| 121 | if (!res.ok) return null; | |
| 122 | return res.json(); | |
| 123 | } catch { | |
| 124 | return null; | |
| 125 | } | |
| 102 | const data = await getRepoCommits(owner, repo, sha, { limit: 1 }); | |
| 103 | return data?.commits?.[0] ?? null; | |
| 126 | 104 | } |
| 127 | 105 | |
| 128 | 106 | export default async function CommitPage({ params }: Props) { |
| @@ -142,7 +120,7 @@ | ||
| 142 | 120 | |
| 143 | 121 | const gitSha = commit.hash ?? sha; |
| 144 | 122 | const parentSha = commit.parents?.[0] ?? null; |
| 145 | const diffData = parentSha ? await getDiff(owner, repo, parentSha, gitSha) : null; | |
| 123 | const diffData = parentSha ? await getRepoDiff(owner, repo, parentSha, gitSha) : null; | |
| 146 | 124 | |
| 147 | 125 | const files: ParsedFile[] = []; |
| 148 | 126 | if (diffData?.diffs) { |
| 149 | 127 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | 3 | import { FileIcon } from "@/app/components/file-icon"; |
| 4 | import { groveApiUrl, encodePath } from "@/lib/utils"; | |
| 4 | import { encodePath } from "@/lib/utils"; | |
| 5 | import { getRepoTree } from "@/lib/grove-api"; | |
| 5 | 6 | |
| 6 | 7 | interface Props { |
| 7 | 8 | params: Promise<{ owner: string; repo: string; path: string[] }>; |
| @@ -13,14 +14,6 @@ | ||
| 13 | 14 | return { title: `${path || "/"} · ${repo}` }; |
| 14 | 15 | } |
| 15 | 16 | |
| 16 | async function getTree(owner: string, repo: string, ref: string, path: string) { | |
| 17 | const url = path | |
| 18 | ? `${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}/${path}` | |
| 19 | : `${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}`; | |
| 20 | const res = await fetch(url, { cache: "no-store" }); | |
| 21 | if (!res.ok) return null; | |
| 22 | return res.json(); | |
| 23 | } | |
| 24 | 17 | |
| 25 | 18 | function sortEntries(entries: any[]) { |
| 26 | 19 | return [...entries].sort((a, b) => { |
| @@ -34,7 +27,7 @@ | ||
| 34 | 27 | const ref = pathParts[0] ?? "main"; |
| 35 | 28 | const path = pathParts.slice(1).join("/"); |
| 36 | 29 | |
| 37 | const tree = await getTree(owner, repo, ref, path); | |
| 30 | const tree = await getRepoTree(owner, repo, ref, path); | |
| 38 | 31 | |
| 39 | 32 | if (!tree) { |
| 40 | 33 | return ( |
| 41 | 34 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import { PipelineRunDetail } from "@/app/components/pipeline-run-detail"; |
| 3 | import { groveApiUrl } from "@/lib/utils"; | |
| 3 | import { getCanopyRun } from "@/lib/grove-api"; | |
| 4 | 4 | |
| 5 | 5 | interface Props { |
| 6 | 6 | params: Promise<{ owner: string; repo: string; runId: string }>; |
| @@ -29,13 +29,8 @@ | ||
| 29 | 29 | const title = `Build #${runId} · ${repo}`; |
| 30 | 30 | |
| 31 | 31 | try { |
| 32 | const res = await fetch( | |
| 33 | `${groveApiUrl}/api/repos/${owner}/${repo}/canopy/runs/${runId}`, | |
| 34 | { cache: "no-store" } | |
| 35 | ); | |
| 36 | if (!res.ok) return { title }; | |
| 37 | ||
| 38 | const data = (await res.json()) as { run?: { status?: string } }; | |
| 32 | const data = (await getCanopyRun(owner, repo, runId)) as { run?: { status?: string } } | null; | |
| 33 | if (!data) return { title }; | |
| 39 | 34 | const status = data.run?.status ?? ""; |
| 40 | 35 | const color = statusFaviconColor[status] ?? "#4d8a78"; |
| 41 | 36 | const svg = getCanopyStatusFaviconSvg(color); |
| 42 | 37 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | import { groveApiUrl } from "@/lib/utils"; | |
| 3 | import { getCanopyRecentRuns } from "@/lib/grove-api"; | |
| 4 | 4 | import { Badge } from "@/app/components/ui/badge"; |
| 5 | 5 | import { TimeAgo } from "@/app/components/time-ago"; |
| 6 | 6 | import { |
| @@ -44,18 +44,9 @@ | ||
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | async function getRepoRuns(owner: string, repo: string): Promise<Run[]> { |
| 47 | try { | |
| 48 | const res = await fetch( | |
| 49 | `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`, | |
| 50 | { cache: "no-store" }, | |
| 51 | ); | |
| 52 | if (!res.ok) return []; | |
| 53 | const data = await res.json(); | |
| 54 | const runs: Run[] = data.runs ?? []; | |
| 55 | return runs.filter((r) => r.repo_name === repo); | |
| 56 | } catch { | |
| 57 | return []; | |
| 58 | } | |
| 47 | const data = await getCanopyRecentRuns<Run>({ owner, limit: 50 }); | |
| 48 | const runs = data?.runs ?? []; | |
| 49 | return runs.filter((r) => r.repo_name === repo); | |
| 59 | 50 | } |
| 60 | 51 | |
| 61 | 52 | export default async function CanopyRepoPage({ params }: Props) { |
| 62 | 53 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import { redirect } from "next/navigation"; |
| 3 | import { groveApiUrl } from "@/lib/utils"; | |
| 3 | import { getCanopyRunsForRepo } from "@/lib/grove-api"; | |
| 4 | 4 | import { |
| 5 | 5 | formatTriggerType, |
| 6 | 6 | getInvocationRunSlug, |
| @@ -36,17 +36,8 @@ | ||
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | async function getRuns(owner: string, repo: string): Promise<Run[]> { |
| 39 | try { | |
| 40 | const res = await fetch( | |
| 41 | `${groveApiUrl}/api/repos/${owner}/${repo}/canopy/runs?limit=200`, | |
| 42 | { cache: "no-store" } | |
| 43 | ); | |
| 44 | if (!res.ok) return []; | |
| 45 | const data = await res.json(); | |
| 46 | return data.runs ?? []; | |
| 47 | } catch { | |
| 48 | return []; | |
| 49 | } | |
| 39 | const data = await getCanopyRunsForRepo<Run>(owner, repo, { limit: 200 }); | |
| 40 | return data?.runs ?? []; | |
| 50 | 41 | } |
| 51 | 42 | |
| 52 | 43 | export default async function CanopyInvocationPage({ params }: Props) { |
| 53 | 44 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | 2 | import Link from "next/link"; |
| 3 | import { groveApiUrl } from "@/lib/utils"; | |
| 3 | import { getCanopyRecentRuns } from "@/lib/grove-api"; | |
| 4 | 4 | import { Badge } from "@/app/components/ui/badge"; |
| 5 | 5 | import { TimeAgo } from "@/app/components/time-ago"; |
| 6 | 6 | import { |
| @@ -44,17 +44,8 @@ | ||
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | async function getOwnerRuns(owner: string): Promise<Run[]> { |
| 47 | try { | |
| 48 | const res = await fetch( | |
| 49 | `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`, | |
| 50 | { cache: "no-store" }, | |
| 51 | ); | |
| 52 | if (!res.ok) return []; | |
| 53 | const data = await res.json(); | |
| 54 | return data.runs ?? []; | |
| 55 | } catch { | |
| 56 | return []; | |
| 57 | } | |
| 47 | const data = await getCanopyRecentRuns<Run>({ owner, limit: 50 }); | |
| 48 | return data?.runs ?? []; | |
| 58 | 49 | } |
| 59 | 50 | |
| 60 | 51 | function groupByRepo(runs: Run[]) { |
| 61 | 52 | |
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import Link from "next/link"; |
| 3 | 3 | import { cookies } from "next/headers"; |
| 4 | 4 | import { headers } from "next/headers"; |
| 5 | import { groveApiUrl } from "@/lib/utils"; | |
| 5 | import { serverFetch } from "@/lib/server-fetch"; | |
| 6 | 6 | import { Badge } from "@/app/components/ui/badge"; |
| 7 | 7 | import { TimeAgo } from "@/app/components/time-ago"; |
| 8 | 8 | import { CanopyLogo } from "@/app/components/canopy-logo"; |
| @@ -48,12 +48,11 @@ | ||
| 48 | 48 | |
| 49 | 49 | async function getRecentRuns(): Promise<RecentRunsResult> { |
| 50 | 50 | try { |
| 51 | const res = await fetch(`${groveApiUrl}/api/canopy/recent-runs?per_repo=20`, { | |
| 52 | cache: "no-store", | |
| 53 | }); | |
| 54 | if (res.status === 401) { | |
| 55 | return { runs: [], unauthorized: true }; | |
| 56 | } | |
| 51 | // Note: this page needs to distinguish 401 (signed out) from | |
| 52 | // other failures, so it uses serverFetch directly rather than the | |
| 53 | // higher-level helpers in grove-api.ts. | |
| 54 | const res = await serverFetch(`/api/canopy/recent-runs?per_repo=20`); | |
| 55 | if (res.status === 401) return { runs: [], unauthorized: true }; | |
| 57 | 56 | if (!res.ok) return { runs: [], unauthorized: false }; |
| 58 | 57 | const data = await res.json(); |
| 59 | 58 | return { runs: data.runs ?? [], unauthorized: false }; |
| 60 | 59 | |
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | import { cookies } from "next/headers"; | |
| 3 | import { headers } from "next/headers"; | |
| 2 | import { cookies, headers } from "next/headers"; | |
| 4 | 3 | import Link from "next/link"; |
| 5 | import { groveApiUrl } from "@/lib/utils"; | |
| 4 | import { listRepos } from "@/lib/grove-api"; | |
| 6 | 5 | import { CollabLogo } from "@/app/components/collab-logo"; |
| 7 | 6 | import { CollabRepoList } from "./collab-repo-list"; |
| 8 | 7 | |
| @@ -18,21 +17,9 @@ | ||
| 18 | 17 | updated_at: string | null; |
| 19 | 18 | } |
| 20 | 19 | |
| 21 | async function getRepos(token: string | undefined): Promise<Repo[]> { | |
| 22 | try { | |
| 23 | const headers: Record<string, string> = token | |
| 24 | ? { Authorization: `Bearer ${token}` } | |
| 25 | : {}; | |
| 26 | const res = await fetch(`${groveApiUrl}/api/repos`, { | |
| 27 | headers, | |
| 28 | cache: "no-store", | |
| 29 | }); | |
| 30 | if (!res.ok) return []; | |
| 31 | const data = await res.json(); | |
| 32 | return data.repos ?? []; | |
| 33 | } catch { | |
| 34 | return []; | |
| 35 | } | |
| 20 | async function getRepos(): Promise<Repo[]> { | |
| 21 | const data = await listRepos<Repo>(); | |
| 22 | return data?.repos ?? []; | |
| 36 | 23 | } |
| 37 | 24 | |
| 38 | 25 | export default async function CollabHomePage() { |
| @@ -95,7 +82,7 @@ | ||
| 95 | 82 | ); |
| 96 | 83 | } |
| 97 | 84 | |
| 98 | const repos = await getRepos(token); | |
| 85 | const repos = await getRepos(); | |
| 99 | 86 | |
| 100 | 87 | return <CollabRepoList repos={repos} />; |
| 101 | 88 | } |
| 102 | 89 | |
| @@ -0,0 +1,103 @@ | ||
| 1 | // Server-side API client. Every named function here forwards the user's | |
| 2 | // auth token via serverFetch, so private-repo access works the same on | |
| 3 | // SSR as it does in the browser. | |
| 4 | // | |
| 5 | // Pages should NOT call `fetch(${groveApiUrl}/...)` directly — that path | |
| 6 | // is anonymous, and private repos look like 404s to their actual owners. | |
| 7 | // Add a function here instead. | |
| 8 | ||
| 9 | import { serverFetch } from "./server-fetch"; | |
| 10 | ||
| 11 | /** All non-OK responses become null. Pages decide whether to render | |
| 12 | * "not found" or "could not load". */ | |
| 13 | async function getJson<T>(path: string): Promise<T | null> { | |
| 14 | try { | |
| 15 | const res = await serverFetch(path); | |
| 16 | if (!res.ok) return null; | |
| 17 | return (await res.json()) as T; | |
| 18 | } catch { | |
| 19 | return null; | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | // ---- Repos ---------------------------------------------------------- | |
| 24 | ||
| 25 | export type RepoBranches = { | |
| 26 | branches?: Array<{ name: string }>; | |
| 27 | bookmarks?: Array<{ name: string }>; | |
| 28 | }; | |
| 29 | ||
| 30 | export const getRepoBranches = (owner: string, repo: string) => | |
| 31 | getJson<RepoBranches>(`/api/repos/${owner}/${repo}/branches`); | |
| 32 | ||
| 33 | export type RepoTree = { | |
| 34 | entries: Array<{ name: string; type: string; size?: number }>; | |
| 35 | }; | |
| 36 | ||
| 37 | export const getRepoTree = (owner: string, repo: string, ref: string, path?: string) => | |
| 38 | getJson<RepoTree>( | |
| 39 | path | |
| 40 | ? `/api/repos/${owner}/${repo}/tree/${ref}/${path}` | |
| 41 | : `/api/repos/${owner}/${repo}/tree/${ref}`, | |
| 42 | ); | |
| 43 | ||
| 44 | // Note: callers assume content + size are present (i.e. text files). For | |
| 45 | // binary blobs the API includes is_binary: true and may omit content; | |
| 46 | // no current page handles that branch. Tighten the type when it does. | |
| 47 | export type RepoBlob = { content: string; size: number; is_binary?: boolean }; | |
| 48 | ||
| 49 | export const getRepoBlob = (owner: string, repo: string, ref: string, path: string) => | |
| 50 | getJson<RepoBlob>(`/api/repos/${owner}/${repo}/blob/${ref}/${path}`); | |
| 51 | ||
| 52 | export type RepoCommitList = { commits: any[] }; | |
| 53 | ||
| 54 | export const getRepoCommits = ( | |
| 55 | owner: string, | |
| 56 | repo: string, | |
| 57 | ref: string, | |
| 58 | opts: { limit?: number } = {}, | |
| 59 | ) => { | |
| 60 | const qs = opts.limit ? `?limit=${opts.limit}` : ""; | |
| 61 | return getJson<RepoCommitList>(`/api/repos/${owner}/${repo}/commits/${ref}${qs}`); | |
| 62 | }; | |
| 63 | ||
| 64 | export type RepoDiff = { diffs: Array<{ path: string; diff: string; is_binary?: boolean }> }; | |
| 65 | ||
| 66 | export const getRepoDiff = (owner: string, repo: string, base: string, head: string) => | |
| 67 | getJson<RepoDiff>(`/api/repos/${owner}/${repo}/diff?base=${base}&head=${head}`); | |
| 68 | ||
| 69 | // The endpoints below return nested objects whose shapes are richer than | |
| 70 | // what each caller cares about. The wrapper types below are generic over | |
| 71 | // the row/run/blame element so callers pass their own local Repo/Run/etc. | |
| 72 | // types: `await listRepos<Repo>()` → `{ repos?: Repo[] }`. Defaults to | |
| 73 | // `unknown` so passing nothing forces an explicit cast at the use site. | |
| 74 | ||
| 75 | export const getRepoDiffs = <T = unknown>(owner: string, repo: string, status: string) => | |
| 76 | getJson<{ diffs?: T[] } & Record<string, unknown>>( | |
| 77 | `/api/repos/${owner}/${repo}/diffs?status=${status}`, | |
| 78 | ); | |
| 79 | ||
| 80 | export const getRepoBlame = <T = unknown>(owner: string, repo: string, ref: string, path: string) => | |
| 81 | getJson<T>(`/api/repos/${owner}/${repo}/blame/${ref}/${path}`); | |
| 82 | ||
| 83 | export const listRepos = <T = unknown>() => | |
| 84 | getJson<{ repos?: T[] }>(`/api/repos`); | |
| 85 | ||
| 86 | export const getCanopyRecentRuns = <T = unknown>( | |
| 87 | opts: { per_repo?: number; owner?: string; limit?: number } = {}, | |
| 88 | ) => { | |
| 89 | const params = new URLSearchParams(); | |
| 90 | if (opts.per_repo) params.set("per_repo", String(opts.per_repo)); | |
| 91 | if (opts.owner) params.set("owner", opts.owner); | |
| 92 | if (opts.limit) params.set("limit", String(opts.limit)); | |
| 93 | const qs = params.toString(); | |
| 94 | return getJson<{ runs?: T[] }>(`/api/canopy/recent-runs${qs ? `?${qs}` : ""}`); | |
| 95 | }; | |
| 96 | ||
| 97 | export const getCanopyRunsForRepo = <T = unknown>(owner: string, repo: string, opts: { limit?: number } = {}) => { | |
| 98 | const qs = opts.limit ? `?limit=${opts.limit}` : ""; | |
| 99 | return getJson<{ runs?: T[] }>(`/api/repos/${owner}/${repo}/canopy/runs${qs}`); | |
| 100 | }; | |
| 101 | ||
| 102 | export const getCanopyRun = <T = unknown>(owner: string, repo: string, runId: string) => | |
| 103 | getJson<{ run?: T }>(`/api/repos/${owner}/${repo}/canopy/runs/${runId}`); | |
| 0 | 104 | |
| @@ -0,0 +1,41 @@ | ||
| 1 | // SSR-side fetch helpers that forward the user's auth token to the API. | |
| 2 | // | |
| 3 | // Auth lives in `grove_hub_token` (issued by hub-api, JWT verified by both | |
| 4 | // hub-api and the grove api with the shared JWT_SECRET). On the client it | |
| 5 | // also lives in localStorage but Next.js server components can only see | |
| 6 | // the cookie. Without forwarding it, every SSR fetch is anonymous, which | |
| 7 | // makes private repos look like 404s to their actual owners. | |
| 8 | ||
| 9 | import { cookies } from "next/headers"; | |
| 10 | import { groveApiUrl } from "./utils"; | |
| 11 | ||
| 12 | const TOKEN_COOKIE = "grove_hub_token"; | |
| 13 | ||
| 14 | /** Read the user's bearer token from cookies. Returns null if missing. */ | |
| 15 | export async function readAuthToken(): Promise<string | null> { | |
| 16 | const store = await cookies(); | |
| 17 | return store.get(TOKEN_COOKIE)?.value ?? null; | |
| 18 | } | |
| 19 | ||
| 20 | /** Build auth headers for a server-side fetch. Empty if no token. */ | |
| 21 | export async function authHeaders(): Promise<Record<string, string>> { | |
| 22 | const token = await readAuthToken(); | |
| 23 | return token ? { authorization: `Bearer ${token}` } : {}; | |
| 24 | } | |
| 25 | ||
| 26 | /** Fetch a Grove API path forwarding the user's auth token. Defaults to | |
| 27 | * no-store so per-user data isn't cached across requests. */ | |
| 28 | export async function serverFetch( | |
| 29 | path: string, | |
| 30 | init: RequestInit = {}, | |
| 31 | ): Promise<Response> { | |
| 32 | const headers = { | |
| 33 | ...(init.headers ?? {}), | |
| 34 | ...(await authHeaders()), | |
| 35 | }; | |
| 36 | return fetch(`${groveApiUrl}${path}`, { | |
| 37 | cache: "no-store", | |
| 38 | ...init, | |
| 39 | headers, | |
| 40 | }); | |
| 41 | } | |
| 0 | 42 | |