feat: error UI on restricted repos (#18)

Normally this says "repo not found". I've made it at least display the
reason.
 
<img width="3116" height="1036" alt="CleanShot 2026-02-22 at 11 50
26@2x"
src="https://github.com/user-attachments/assets/8ba05114-62fe-4fd3-b29e-fc07715e3b8f"
/>
This commit is contained in:
Maxwell
2026-02-22 12:02:11 -08:00
committed by GitHub
parent 8be5350c96
commit 29f8c7db03
3 changed files with 161 additions and 47 deletions
@@ -15,6 +15,74 @@ import {
} from "@/lib/repo-data-cache-vc";
import { setCachedRepoTree } from "@/lib/repo-data-cache";
import { waitUntil } from "@vercel/functions";
import { ExternalLink, ShieldAlert, AlertCircle } from "lucide-react";
function RepoErrorPage({ owner, repo, error }: { owner: string; repo: string; error: string }) {
const githubUrl = `https://github.com/${owner}/${repo}`;
const isOAuthRestriction = error.includes("OAuth App access restrictions");
const isNotFound = error === "Repository not found";
return (
<div className="py-16 flex flex-col items-center justify-center gap-4 text-center max-w-md mx-auto">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">
{isOAuthRestriction ? (
<ShieldAlert className="w-6 h-6 text-amber-500" />
) : (
<AlertCircle className="w-6 h-6 text-muted-foreground/50" />
)}
</div>
<div className="space-y-2">
<h1 className="text-sm font-medium">
{isOAuthRestriction
? "Access Restricted"
: isNotFound
? "Repository not found"
: "Unable to load repository"}
</h1>
{isOAuthRestriction ? (
<p className="text-xs text-muted-foreground/80 leading-relaxed">
The{" "}
<span className="font-medium text-foreground">
{owner}
</span>{" "}
organization has enabled OAuth App access
restrictions. To view this repository, an
organization admin needs to approve this app, or you
can view it directly on GitHub.
</p>
) : isNotFound ? (
<p className="text-xs text-muted-foreground/80">
This repository doesn&apos;t exist or you don&apos;t
have permission to view it.
</p>
) : (
<p className="text-xs text-muted-foreground/80 leading-relaxed font-mono">
{error}
</p>
)}
</div>
<a
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-[11px] font-mono px-3 py-1.5 border border-border text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
<ExternalLink className="w-3 h-3" />
View on GitHub
</a>
{isOAuthRestriction && (
<a
href="https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/approving-oauth-apps-for-your-organization"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors underline underline-offset-2"
>
Learn about OAuth app approval
</a>
)}
</div>
);
}
export default async function RepoLayout({
children,
@@ -34,18 +102,13 @@ export default async function RepoLayout({
getCachedTags(owner, repoName),
]);
const pageData = await pageDataPromise;
if (!pageData) {
return (
<div className="py-16 text-center">
<p className="text-xs text-muted-foreground font-mono">
Repository not found
</p>
</div>
);
const pageDataResult = await pageDataPromise;
if (!pageDataResult.success) {
return <RepoErrorPage owner={owner} repo={repoName} error={pageDataResult.error} />;
}
const { repoData, navCounts, viewerHasStarred, viewerIsOrgMember, latestCommit } = pageData;
const { repoData, navCounts, viewerHasStarred, viewerIsOrgMember, latestCommit } =
pageDataResult.data;
waitUntil(prefetchPRData(owner, repoName, { prefetchIssues: !repoData.private }));
@@ -22,10 +22,10 @@ export default async function RepoPage({
const pageDataPromise = getRepoPageData(owner, repo);
const readmePromise = getCachedReadmeHtml(owner, repo);
const pageData = await pageDataPromise;
if (!pageData) return null;
const pageDataResult = await pageDataPromise;
if (!pageDataResult.success) return null;
const { repoData, navCounts } = pageData;
const { repoData, navCounts } = pageDataResult.data;
const { permissions } = repoData;
const isMaintainer = permissions.push || permissions.admin || permissions.maintain;
@@ -38,7 +38,7 @@ export default async function RepoPage({
initialCommitActivity,
initialCIStatus,
initialPinnedItems,
] = await Promise.all([
] = (await Promise.all([
readmePromise,
isMaintainer ? getCachedOverviewPRs(owner, repo) : null,
isMaintainer ? getCachedOverviewIssues(owner, repo) : null,
@@ -46,7 +46,7 @@ export default async function RepoPage({
isMaintainer ? getCachedOverviewCommitActivity(owner, repo) : null,
isMaintainer ? getCachedOverviewCI(owner, repo) : null,
isMaintainer ? fetchPinnedItemsForRepo(owner, repo) : null,
]) as [
])) as [
string | null,
RepoOverviewProps["initialPRs"],
RepoOverviewProps["initialIssues"],
@@ -56,7 +56,8 @@ export default async function RepoPage({
RepoOverviewProps["initialPinnedItems"],
];
const readmeHtml = readmeHtmlRaw ?? await revalidateReadme(owner, repo, repoData.default_branch);
const readmeHtml =
readmeHtmlRaw ?? (await revalidateReadme(owner, repo, repoData.default_branch));
return (
<div className={isMaintainer ? "flex flex-col flex-1 min-h-0" : undefined}>
+81 -31
View File
@@ -121,7 +121,6 @@ interface LocalFirstGitReadOptions<T> {
fetchRemote: (octokit: Octokit) => Promise<T>;
}
const globalForGithubSync = globalThis as typeof globalThis & {
__githubSyncDrainingUsers?: Set<string>;
};
@@ -2777,7 +2776,7 @@ function mapGraphQLIssueNode(node: Record<string, any>): RepoIssueNode {
number: node.number,
title: node.title,
state: (node.state as string).toLowerCase(),
state_reason: node.stateReason ? stateReasonMap[node.stateReason] ?? null : null,
state_reason: node.stateReason ? (stateReasonMap[node.stateReason] ?? null) : null,
updated_at: node.updatedAt,
created_at: node.createdAt,
closed_at: node.closedAt ?? null,
@@ -2838,10 +2837,7 @@ function buildRepoIssuesPageCacheKey(owner: string, repo: string): string {
return `repo_issues_page:${normalizeRepoKey(owner, repo)}`;
}
export async function getRepoIssuesPage(
owner: string,
repo: string,
): Promise<RepoIssuesPageData> {
export async function getRepoIssuesPage(owner: string, repo: string): Promise<RepoIssuesPageData> {
const authCtx = await getGitHubAuthContext();
const fallback: RepoIssuesPageData = {
openIssues: [],
@@ -3061,7 +3057,10 @@ function mapGraphQLPRNode(pr: Record<string, unknown>) {
(
pr.reviewRequests as {
nodes: {
requestedReviewer: { login: string; avatarUrl: string } | null;
requestedReviewer: {
login: string;
avatarUrl: string;
} | null;
}[];
}
)?.nodes ?? []
@@ -3122,7 +3121,12 @@ export async function getRepoPullRequestsWithStats(
owner: string,
repo: string,
state: "open" | "closed" | "all" = "open",
opts?: { includeCounts?: boolean; previewClosed?: number; perPage?: number; cursor?: string | null },
opts?: {
includeCounts?: boolean;
previewClosed?: number;
perPage?: number;
cursor?: string | null;
},
): Promise<PRPageResult> {
const token = await getGitHubToken();
if (!token) return EMPTY_PAGE_RESULT;
@@ -3206,11 +3210,21 @@ export async function getRepoPullRequestsWithStats(
const mergedPreview =
previewCount > 0
? ((repo_data.mergedPreview?.nodes ?? []) as Record<string, unknown>[]).map(mapGraphQLPRNode)
? (
(repo_data.mergedPreview?.nodes ?? []) as Record<
string,
unknown
>[]
).map(mapGraphQLPRNode)
: [];
const closedPreview =
previewCount > 0
? ((repo_data.closedPreview?.nodes ?? []) as Record<string, unknown>[]).map(mapGraphQLPRNode)
? (
(repo_data.closedPreview?.nodes ?? []) as Record<
string,
unknown
>[]
).map(mapGraphQLPRNode)
: [];
return { prs, pageInfo, counts, mergedPreview, closedPreview };
@@ -3524,7 +3538,11 @@ export async function getCachedCheckStatus(
return null;
}
export async function prefetchPRData(owner: string, repo: string, opts?: { prefetchIssues?: boolean }) {
export async function prefetchPRData(
owner: string,
repo: string,
opts?: { prefetchIssues?: boolean },
) {
try {
const rKey = checkStatusRedisKey(owner, repo);
const cached = await redis.get(rKey);
@@ -3532,7 +3550,13 @@ export async function prefetchPRData(owner: string, repo: string, opts?: { prefe
? Promise.resolve()
: getRepoPullRequestsWithStats(owner, repo, "open").then(({ prs }) =>
prs.length > 0
? batchFetchCheckStatuses(owner, repo, prs.map((pr) => ({ number: pr.number }))).then(() => {})
? batchFetchCheckStatuses(
owner,
repo,
prs.map((pr) => ({
number: pr.number,
})),
).then(() => {})
: undefined,
);
@@ -4283,6 +4307,10 @@ export async function getLanguages(owner: string, repo: string): Promise<Record<
// --- Combined repo page data via single GraphQL call ---
export type RepoPageDataResult =
| { success: true; data: RepoPageData }
| { success: false; error: string };
export interface RepoPageData {
repoData: {
description?: string;
@@ -4404,11 +4432,25 @@ async function fetchRepoPageDataGraphQL(
if (!response.ok) throw new Error(`GraphQL request failed: ${response.status}`);
const json = await response.json();
const r = json.data?.repository;
if (!r) return null;
const viewerIsOrgMember: boolean =
json.data?.organization?.viewerIsAMember ?? false;
if (json.errors?.length) {
const errorMessages = json.errors
.map((e: { message: string }) => e.message)
.join("; ");
console.error(
`[fetchRepoPageDataGraphQL] GitHub API error for ${owner}/${repo}:`,
errorMessages,
);
throw new Error(errorMessages);
}
const r = json.data?.repository;
if (!r) {
console.warn(`[fetchRepoPageDataGraphQL] Repository not found: ${owner}/${repo}`);
return null;
}
const viewerIsOrgMember: boolean = json.data?.organization?.viewerIsAMember ?? false;
const languages: Record<string, number> = {};
for (const edge of r.languages?.edges ?? []) {
@@ -4486,10 +4528,10 @@ async function fetchRepoPageDataGraphQL(
/* eslint-enable @typescript-eslint/no-explicit-any */
export const getRepoPageData = cache(
async (owner: string, repo: string): Promise<RepoPageData | null> => {
async (owner: string, repo: string): Promise<RepoPageDataResult> => {
const { getCachedRepoPageData } = await import("@/lib/repo-data-cache-vc");
const cached = await getCachedRepoPageData<RepoPageData>(owner, repo);
if (cached) return cached;
if (cached) return { success: true, data: cached };
return fetchAndCacheRepoPageData(owner, repo);
},
@@ -4498,13 +4540,13 @@ export const getRepoPageData = cache(
export async function fetchAndCacheRepoPageData(
owner: string,
repo: string,
): Promise<RepoPageData | null> {
): Promise<RepoPageDataResult> {
const authCtx = await getGitHubAuthContext();
if (!authCtx) return null;
if (!authCtx) return { success: false, error: "Not authenticated" };
try {
const result = await fetchRepoPageDataGraphQL(authCtx.token, owner, repo);
if (!result) return null;
if (!result) return { success: false, error: "Repository not found" };
const { setCachedRepoPageData } = await import("@/lib/repo-data-cache");
const navCountsKey = buildRepoNavCountsCacheKey(owner, repo);
@@ -4526,9 +4568,11 @@ export async function fetchAndCacheRepoPageData(
cacheDefaultBranch(owner, repo, result.repoData.default_branch),
]);
return result;
} catch {
return null;
return { success: true, data: result };
} catch (error) {
console.error(`[fetchAndCacheRepoPageData] Failed for ${owner}/${repo}:`, error);
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message };
}
}
@@ -4540,7 +4584,8 @@ export async function getRepoOverviewData(
languages: Record<string, number>;
}> {
const result = await getRepoPageData(owner, repo);
if (result) return { navCounts: result.navCounts, languages: result.languages };
if (result.success)
return { navCounts: result.data.navCounts, languages: result.data.languages };
return {
navCounts: { openPrs: 0, openIssues: 0, activeRuns: 0 },
languages: {},
@@ -4732,7 +4777,11 @@ export async function getAuthorDossier(
authorLogin: string,
): Promise<AuthorDossierResult | null> {
try {
const cached = await getCachedAuthorDossier<AuthorDossierResult>(owner, repo, authorLogin);
const cached = await getCachedAuthorDossier<AuthorDossierResult>(
owner,
repo,
authorLogin,
);
if (cached) return cached;
const token = await getGitHubToken();
@@ -4785,11 +4834,12 @@ export async function getAuthorDossier(
const u = json.data?.user;
if (!u) return null;
const orgs: { login: string; avatar_url: string }[] =
(u.organizations?.nodes ?? []).map((o: any) => ({
login: o.login,
avatar_url: o.avatarUrl,
}));
const orgs: { login: string; avatar_url: string }[] = (
u.organizations?.nodes ?? []
).map((o: any) => ({
login: o.login,
avatar_url: o.avatarUrl,
}));
const topRepos = (u.topRepositories?.nodes ?? []).map((r: any) => ({
name: r.name,
full_name: r.nameWithOwner,