Add git sync status for forked repos

This commit is contained in:
Sam Becker 2025-01-30 22:45:22 -06:00
parent 843c7046b2
commit 10c7ba4240
11 changed files with 244 additions and 82 deletions

View File

@ -0,0 +1,14 @@
import { Suspense } from 'react';
import GitHubForkStatusBadgeClient from './GitHubForkStatusBadgeClient';
import GitHubForkStatusBadgeServer from './GitHubForkStatusBadgeServer';
import { IS_DEVELOPMENT, IS_VERCEL_GIT_PROVIDER_GITHUB } from '@/site/config';
export default function GitHubForkStatusBadge() {
return IS_DEVELOPMENT
? <GitHubForkStatusBadgeClient label="Local" />
: IS_VERCEL_GIT_PROVIDER_GITHUB
? <Suspense>
<GitHubForkStatusBadgeServer />
</Suspense>
: null;
}

View File

@ -0,0 +1,74 @@
import Spinner from '@/components/Spinner';
import clsx from 'clsx/lite';
import Link from 'next/link';
import { BiLogoGithub } from 'react-icons/bi';
export default function GitHubForkStatusBadgeClient({
url,
label,
style = 'mono',
title,
}: {
url?: string
label?: string
style?: 'success' | 'warning' | 'mono'
title?: string
}) {
const classNameForStyle = () => {
switch (style) {
case 'success': return clsx(
'text-green-700 hover:text-green-700',
'dark:text-green-400 dark:hover:text-green-400',
'bg-green-100/75 dark:bg-green-900/50',
'border-green-300/25',
);
case 'warning': return clsx(
'text-amber-700 hover:text-amber-700',
'dark:text-amber-400 dark:hover:text-amber-400',
'bg-amber-100/75 dark:bg-amber-900/50',
'border-amber-300/25 dark:border-amber-900',
);
default: return clsx(
'text-gray-700 hover:text-gray-700',
'dark:text-gray-300 dark:hover:text-gray-300',
'bg-gray-100/75 dark:bg-gray-900/50',
'border-main',
);
}
};
const className = clsx(
'inline-flex items-center gap-2',
'border transition-colors',
url ? 'hover:underline' : 'select-none',
'pl-[4.5px] pr-2 py-[3px]',
'rounded-full',
classNameForStyle(),
);
const content = <>
{!label
? <Spinner
color="text"
className="translate-x-[3px]"
/>
: <BiLogoGithub size={17} />}
{label ?? 'Checking'}
</>;
return url
? <Link
target="_blank"
href={url}
title={title}
className={className}
>
{content}
</Link>
: <span
title={title}
className={className}
>
{content}
</span>;
}

View File

@ -0,0 +1,27 @@
import GitHubForkStatusBadgeClient from './GitHubForkStatusBadgeClient';
import {
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
} from '@/site/config';
import { getGitHubMeta } from '.';
export default async function GitHubForkStatusBadgeServer() {
const owner = VERCEL_GIT_REPO_OWNER;
const repo = VERCEL_GIT_REPO_SLUG;
const {
url,
label,
title,
isBehind,
} = await getGitHubMeta({ owner, repo });
return (
<GitHubForkStatusBadgeClient {...{
url,
label,
title,
style: isBehind ? 'warning' : 'mono',
}} />
);
}

90
src/admin/github/index.ts Normal file
View File

@ -0,0 +1,90 @@
const BASE_OWNER = 'sambecker';
const BASE_REPO = 'exif-photo-blog';
interface RepoParams {
owner?: string
repo?: string
branch?: string
};
// Urls
const getGitHubRepoUrl = ({
owner = BASE_OWNER,
repo = BASE_REPO,
}: RepoParams = {}) =>
`https://github.com/${owner}/${repo}`;
const getGitHubApiRepoUrl = ({
owner = BASE_OWNER,
repo = BASE_REPO,
}: RepoParams = {}) =>
`https://api.github.com/repos/${owner}/${repo}`;
const getGitHubApiCommitsUrl = (params?: RepoParams) =>
`${getGitHubApiRepoUrl(params)}/commits/main`;
// Fetching
const getGitHubApiCompareUrl = ({
owner,
repo,
branch = 'main',
}: RepoParams = {}) =>
`${getGitHubApiRepoUrl()}/compare/main...${owner}:${repo}:${branch}`;
const getLatestBaseRepoCommitSha = async () => {
const response = await fetch(getGitHubApiCommitsUrl());
const data = await response.json();
return data.sha.slice(0, 7) as string;
};
const getIsRepoForkedFromBase = async (params: RepoParams) => {
const response = await fetch(getGitHubApiRepoUrl(params));
const data = await response.json();
return data.fork && data.source?.full_name === `${BASE_OWNER}/${BASE_REPO}`;
};
const getGitHubCommitsBehind = async (params?: RepoParams) => {
const response = await fetch(getGitHubApiCompareUrl(params));
const data = await response.json();
return data.behind_by as number;
};
const isRepoBaseRepo = async ({ owner, repo }: RepoParams) =>
owner?.toLowerCase() === BASE_OWNER &&
repo?.toLowerCase() === BASE_REPO;
export const getGitHubMeta = async (params: RepoParams) => {
const [
url,
isForkedFromBase,
isBaseRepo,
latestBaseRepoCommitSha,
behindBy,
] = await Promise.all([
getGitHubRepoUrl(params),
getIsRepoForkedFromBase(params),
isRepoBaseRepo(params),
getLatestBaseRepoCommitSha(),
getGitHubCommitsBehind(params),
]);
const isBehind = behindBy > 0;
const label = isBehind ? `${behindBy} Behind` : 'Synced';
const title = isBehind
// eslint-disable-next-line max-len
? `This fork is ${behindBy} commit${behindBy === 1 ? '' : 's'} behind. Consider syncing on GitHub for the latest updates.`
: 'This fork is up to date.';
return {
url,
isForkedFromBase,
isBaseRepo,
latestBaseRepoCommitSha,
behindBy,
isBehind,
label,
title,
};
};

View File

@ -1,6 +1,8 @@
import ClearCacheButton from '@/admin/ClearCacheButton';
import GitHubForkStatusBadge from '@/admin/github/GitHubForkStatusBadge';
import Container from '@/components/Container';
import SiteGrid from '@/components/SiteGrid';
import { IS_DEVELOPMENT, IS_VERCEL_GIT_PROVIDER_GITHUB } from '@/site/config';
import SiteChecklist from '@/site/SiteChecklist';
export default async function AdminConfigurationPage() {
@ -8,10 +10,12 @@ export default async function AdminConfigurationPage() {
<SiteGrid
contentMain={
<div className="space-y-4">
<div className="flex items-center">
<div className="flex-grow">
<div className="flex items-center gap-4">
<div className="grow">
App Configuration
</div>
{(IS_VERCEL_GIT_PROVIDER_GITHUB || IS_DEVELOPMENT) &&
<GitHubForkStatusBadge />}
<ClearCacheButton />
</div>
<Container spaceChildren={false}>

View File

@ -11,7 +11,7 @@ export default function SiteChecklist({
return (
<Suspense fallback={<SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS,
isTestingConnections: true,
isAnalyzingConfiguration: true,
simplifiedView,
}} /> }>
<SiteChecklistServer {...{ simplifiedView }} />

View File

@ -94,17 +94,13 @@ export default function SiteChecklistClient({
storageError,
kvError,
aiError,
// Git Meta
isForkedFromBaseRepo,
// Component props
simplifiedView,
isTestingConnections,
isAnalyzingConfiguration,
}: ConfigChecklistStatus &
Partial<Awaited<ReturnType<typeof testConnectionsAction>>> & {
simplifiedView?: boolean
isTestingConnections?: boolean
} & {
isForkedFromBaseRepo?: boolean
isAnalyzingConfiguration?: boolean
}) {
const renderLink = (href: string, text: string, external = true) =>
<>
@ -216,11 +212,11 @@ export default function SiteChecklistClient({
icon={<BiData size={16} />}
>
<ChecklistRow
title={hasDatabase && isTestingConnections
title={hasDatabase && isAnalyzingConfiguration
? 'Testing database connection'
: 'Setup database'}
status={hasDatabase}
isPending={hasDatabase && isTestingConnections}
isPending={hasDatabase && isAnalyzingConfiguration}
>
{databaseError && renderError({
connection: { provider: 'Database', error: databaseError},
@ -247,7 +243,7 @@ export default function SiteChecklistClient({
</ChecklistRow>
<ChecklistRow
title={
hasStorageProvider && isTestingConnections
hasStorageProvider && isAnalyzingConfiguration
? 'Testing storage connection'
: !hasStorageProvider
? 'Setup storage (one of the following)'
@ -256,7 +252,7 @@ export default function SiteChecklistClient({
? `Setup storage (new uploads go to: ${labelForStorage(currentStorage)})`
: 'Setup storage'}
status={hasStorageProvider}
isPending={hasStorageProvider && isTestingConnections}
isPending={hasStorageProvider && isAnalyzingConfiguration}
>
{storageError && renderError({
connection: { provider: 'Storage', error: storageError},
@ -302,11 +298,11 @@ export default function SiteChecklistClient({
icon={<BiLockAlt size={16} />}
>
<ChecklistRow
title={!hasAuthSecret && isTestingConnections
title={!hasAuthSecret && isAnalyzingConfiguration
? 'Generating secret'
: 'Setup auth'}
status={hasAuthSecret}
isPending={!hasAuthSecret && isTestingConnections}
isPending={!hasAuthSecret && isAnalyzingConfiguration}
>
Store auth secret in environment variable:
{!hasAuthSecret &&
@ -378,11 +374,11 @@ export default function SiteChecklistClient({
optional
>
<ChecklistRow
title={isAiTextGenerationEnabled && isTestingConnections
title={isAiTextGenerationEnabled && isAnalyzingConfiguration
? 'Testing OpenAI connection'
: 'Add OpenAI secret key'}
status={isAiTextGenerationEnabled}
isPending={isAiTextGenerationEnabled && isTestingConnections}
isPending={isAiTextGenerationEnabled && isAnalyzingConfiguration}
optional
>
{aiError && renderError({
@ -394,11 +390,11 @@ export default function SiteChecklistClient({
{renderEnvVars(['OPENAI_SECRET_KEY'])}
</ChecklistRow>
<ChecklistRow
title={hasVercelKv && isTestingConnections
title={hasVercelKv && isAnalyzingConfiguration
? 'Testing KV connection'
: 'Enable rate limiting'}
status={hasVercelKv}
isPending={hasVercelKv && isTestingConnections}
isPending={hasVercelKv && isAnalyzingConfiguration}
optional
>
{kvError && renderError({
@ -669,18 +665,13 @@ export default function SiteChecklistClient({
&nbsp;&nbsp;
{commitSha
? commitUrl
? <>
<Link
title={commitMessage}
href={commitUrl}
target="_blank"
>
{commitSha}
</Link>
&nbsp;
{isForkedFromBaseRepo &&
<span className="text-dim">Forked</span>}
</>
? <Link
title={commitMessage}
href={commitUrl}
target="_blank"
>
{commitSha}
</Link>
: <span title={commitMessage}>{commitSha}</span>
: 'Not Found'}
</div>

View File

@ -1,11 +1,6 @@
import SiteChecklistClient from './SiteChecklistClient';
import {
CONFIG_CHECKLIST_STATUS,
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
} from '@/site/config';
import { CONFIG_CHECKLIST_STATUS } from '@/site/config';
import { testConnectionsAction } from '@/admin/actions';
import { isRepoForkedFromBase } from '@/utility/github';
export default async function SiteChecklistServer({
simplifiedView,
@ -13,16 +8,11 @@ export default async function SiteChecklistServer({
simplifiedView?: boolean
}) {
const connectionErrors = await testConnectionsAction().catch(() => ({}));
const isForkedFromBaseRepo = await isRepoForkedFromBase(
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
);
return (
<SiteChecklistClient {...{
...CONFIG_CHECKLIST_STATUS,
...connectionErrors,
isForkedFromBaseRepo,
simplifiedView,
}} />
);

View File

@ -27,19 +27,19 @@ export const VERCEL_GIT_COMMIT_SHA =
export const VERCEL_GIT_COMMIT_SHA_SHORT = VERCEL_GIT_COMMIT_SHA
? VERCEL_GIT_COMMIT_SHA.slice(0, 7)
: undefined;
export const VERCEL_IS_PROVIDER_GITHUB = VERCEL_GIT_PROVIDER === 'github';
export const VERCEL_GIT_COMMIT_URL = VERCEL_IS_PROVIDER_GITHUB
export const IS_VERCEL_GIT_PROVIDER_GITHUB = VERCEL_GIT_PROVIDER === 'github';
export const VERCEL_GIT_COMMIT_URL = IS_VERCEL_GIT_PROVIDER_GITHUB
// eslint-disable-next-line max-len
? `https://github.com/${VERCEL_GIT_REPO_OWNER}/${VERCEL_GIT_REPO_SLUG}/commit/${VERCEL_GIT_COMMIT_SHA}`
: undefined;
const VERCEL_ENV = process.env.NEXT_PUBLIC_VERCEL_ENV;
const VERCEL_PRODUCTION_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL;
const VERCEL_DEPLOYMENT_URL = process.env.NEXT_PUBLIC_VERCEL_URL;
const VERCEL_BRANCH_URL = process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL;
const VERCEL_BRANCH = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF;
export const VERCEL_ENV = process.env.NEXT_PUBLIC_VERCEL_ENV;
export const VERCEL_PRODUCTION_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL;
export const VERCEL_DEPLOYMENT_URL = process.env.NEXT_PUBLIC_VERCEL_URL;
export const VERCEL_BRANCH_URL = process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL;
export const VERCEL_BRANCH = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF;
// Last resort: cannot be used reliably
const VERCEL_PROJECT_URL = VERCEL_BRANCH_URL && VERCEL_BRANCH
export const VERCEL_PROJECT_URL = VERCEL_BRANCH_URL && VERCEL_BRANCH
? `${VERCEL_BRANCH_URL.split(`-git-${VERCEL_BRANCH}-`)[0]}.vercel.app`
: undefined;
@ -49,6 +49,7 @@ export const IS_PRODUCTION = process.env.NODE_ENV === 'production' && (
!VERCEL_ENV
);
export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development';
export const IS_PREVIEW = VERCEL_ENV === 'preview';
export const VERCEL_BYPASS_KEY = 'x-vercel-protection-bypass';

View File

@ -155,6 +155,9 @@
text-red-500 dark:text-red-400
}
/* Utilities: Border */
.border-main {
@apply border-gray-200 dark:border-gray-700
}
.border-subtle {
@apply
border border-gray-200 dark:border-gray-800

View File

@ -1,32 +0,0 @@
import { VERCEL_IS_PROVIDER_GITHUB } from '@/site/config';
const BASE_OWNER = 'sambecker';
const BASE_REPO = 'exif-photo-blog';
type RepoParams = Parameters<(owner?: string, repo?: string) => unknown>;
const getRepoUrl = (owner = BASE_OWNER, repo = BASE_REPO) =>
`https://api.github.com/repos/${owner}/${repo}`;
const getCommitsUrl = (...args: RepoParams) =>
`${getRepoUrl(...args)}/commits/main`;
export const fetchLatestBaseRepoCommitSha = async () => {
if (VERCEL_IS_PROVIDER_GITHUB) {
const response = await fetch(getCommitsUrl());
const data = await response.json();
return data.sha.slice(0, 7);
} else {
return undefined;
}
};
export const isRepoForkedFromBase = async (...args: RepoParams) => {
if (VERCEL_IS_PROVIDER_GITHUB) {
const response = await fetch(getRepoUrl(...args));
const data = await response.json();
return data.fork && data.source?.full_name === `${BASE_OWNER}/${BASE_REPO}`;
} else {
return false;
}
};