mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
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:
@@ -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't exist or you don'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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user