diff --git a/.env.example b/.env.example index 5cfebc7..aa43c6e 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,7 @@ DOCKER_TAG=latest # INCLUDE_ARCHIVED=false # SKIP_FORKS=false # MIRROR_STARRED=false +# MIRROR_STARRED_LISTS=homelab,dottools # Optional: comma-separated star list names; empty = all starred repos # STARRED_REPOS_ORG=starred # Organization name for starred repos # STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 557a3c8..6a83963 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -61,6 +61,7 @@ Settings for connecting to and configuring GitHub repository sources. | `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` | | `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` | | `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` | +| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) | | `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string | | `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` | diff --git a/docs/images/starred-lists-ui.png b/docs/images/starred-lists-ui.png new file mode 100644 index 0000000..d301c4d Binary files /dev/null and b/docs/images/starred-lists-ui.png differ diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index f557a16..254eccc 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -42,6 +42,7 @@ export function ConfigTabs() { token: '', privateRepositories: false, mirrorStarred: false, + starredLists: [], }, giteaConfig: { url: '', diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx index f9da909..64bf005 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, @@ -17,6 +18,7 @@ import { } from "@/components/ui/popover"; import { Info, + Check, GitBranch, Star, Lock, @@ -31,7 +33,9 @@ import { ChevronDown, Funnel, HardDrive, - FileCode2 + FileCode2, + Plus, + X } from "lucide-react"; import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config"; import { @@ -41,7 +45,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { cn } from "@/lib/utils"; +import { githubApi } from "@/lib/api"; interface GitHubMirrorSettingsProps { githubConfig: GitHubConfig; @@ -60,8 +73,42 @@ export function GitHubMirrorSettings({ onMirrorOptionsChange, onAdvancedOptionsChange, }: GitHubMirrorSettingsProps) { - - const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => { + const [starListsOpen, setStarListsOpen] = React.useState(false); + const [starListSearch, setStarListSearch] = React.useState(""); + const [customStarListName, setCustomStarListName] = React.useState(""); + const [availableStarLists, setAvailableStarLists] = React.useState([]); + const [loadingStarLists, setLoadingStarLists] = React.useState(false); + const [loadedStarLists, setLoadedStarLists] = React.useState(false); + const [attemptedStarListLoad, setAttemptedStarListLoad] = React.useState(false); + + const normalizeStarListNames = React.useCallback((lists: string[] | undefined): string[] => { + if (!Array.isArray(lists)) return []; + + const seen = new Set(); + const normalized: string[] = []; + for (const list of lists) { + const trimmed = list.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + normalized.push(trimmed); + } + + return normalized; + }, []); + + const selectedStarLists = React.useMemo( + () => normalizeStarListNames(githubConfig.starredLists), + [githubConfig.starredLists, normalizeStarListNames], + ); + + const allKnownStarLists = React.useMemo( + () => normalizeStarListNames([...availableStarLists, ...selectedStarLists]), + [availableStarLists, selectedStarLists, normalizeStarListNames], + ); + + const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string | string[]) => { onGitHubConfigChange({ ...githubConfig, [field]: value }); }; @@ -83,6 +130,59 @@ export function GitHubMirrorSettings({ onAdvancedOptionsChange({ ...advancedOptions, [field]: value }); }; + const setSelectedStarLists = React.useCallback((lists: string[]) => { + onGitHubConfigChange({ + ...githubConfig, + starredLists: normalizeStarListNames(lists), + }); + }, [githubConfig, normalizeStarListNames, onGitHubConfigChange]); + + const loadStarLists = React.useCallback(async () => { + if ( + loadingStarLists || + loadedStarLists || + attemptedStarListLoad || + !githubConfig.mirrorStarred + ) return; + + setAttemptedStarListLoad(true); + setLoadingStarLists(true); + try { + const response = await githubApi.getStarredLists(); + setAvailableStarLists(normalizeStarListNames(response.lists)); + setLoadedStarLists(true); + } catch { + // Keep UX usable with manual custom input even if list fetch fails. + // Allow retry on next popover open. + setLoadedStarLists(false); + } finally { + setLoadingStarLists(false); + } + }, [ + attemptedStarListLoad, + githubConfig.mirrorStarred, + loadedStarLists, + loadingStarLists, + normalizeStarListNames, + ]); + + React.useEffect(() => { + if (!starListsOpen || !githubConfig.mirrorStarred) return; + void loadStarLists(); + }, [starListsOpen, githubConfig.mirrorStarred, loadStarLists]); + + React.useEffect(() => { + if (!githubConfig.mirrorStarred) { + setStarListsOpen(false); + } + }, [githubConfig.mirrorStarred]); + + React.useEffect(() => { + if (!starListsOpen) { + setAttemptedStarListLoad(false); + } + }, [starListsOpen]); + // When metadata is disabled, all components should be disabled const isMetadataEnabled = mirrorOptions.mirrorMetadata; @@ -98,6 +198,17 @@ export function GitHubMirrorSettings({ const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length; const totalStarredOptions = 4; // releases, issues, PRs, wiki + const normalizedStarListSearch = starListSearch.trim(); + const canAddSearchAsStarList = normalizedStarListSearch.length > 0 + && !allKnownStarLists.some((list) => list.toLowerCase() === normalizedStarListSearch.toLowerCase()); + + const addCustomStarList = () => { + const trimmed = customStarListName.trim(); + if (!trimmed) return; + setSelectedStarLists([...selectedStarLists, trimmed]); + setCustomStarListName(""); + }; + return (
{/* Repository Selection Section */} @@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
)} + {/* Star list selection */} + {githubConfig.mirrorStarred && ( +
+ + + + + + + + + + + {loadingStarLists ? "Loading star lists..." : "No matching lists"} + + + {allKnownStarLists.map((list) => { + const isSelected = selectedStarLists.some( + (selected) => selected.toLowerCase() === list.toLowerCase(), + ); + + return ( + { + if (isSelected) { + setSelectedStarLists( + selectedStarLists.filter( + (selected) => selected.toLowerCase() !== list.toLowerCase(), + ), + ); + } else { + setSelectedStarLists([...selectedStarLists, list]); + } + }} + > + + {list} + + ); + })} + + + + + {canAddSearchAsStarList && ( +
+ +
+ )} +
+
+ +

+ Leave empty to mirror all starred repositories. Select one or more lists to limit syncing. +

+ + {selectedStarLists.length > 0 && ( +
+ {selectedStarLists.map((list) => ( + + {list} + + + ))} +
+ )} + +
+ setCustomStarListName(event.target.value)} + placeholder="Add custom list name" + className="h-8 text-xs" + /> + +
+
+ )} + {/* Duplicate name handling for starred repos */} {githubConfig.mirrorStarred && (
diff --git a/src/lib/api.ts b/src/lib/api.ts index da66b12..0840bd6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -78,6 +78,10 @@ export const githubApi = { method: "POST", body: JSON.stringify({ token }), }), + getStarredLists: () => + apiRequest<{ success: boolean; lists: string[] }>("/github/starred-lists", { + method: "GET", + }), }; // Gitea API diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 231c206..589db66 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -26,6 +26,7 @@ export const githubConfigSchema = z.object({ includeOrganizations: z.array(z.string()).default([]), starredReposOrg: z.string().optional(), starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"), + starredLists: z.array(z.string()).default([]), mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"), defaultOrg: z.string().optional(), starredCodeOnly: z.boolean().default(false), diff --git a/src/lib/env-config-loader.ts b/src/lib/env-config-loader.ts index ef2a7e9..497545a 100644 --- a/src/lib/env-config-loader.ts +++ b/src/lib/env-config-loader.ts @@ -25,6 +25,7 @@ interface EnvConfig { autoMirrorStarred?: boolean; starredReposOrg?: string; starredReposMode?: 'dedicated-org' | 'preserve-owner'; + starredLists?: string[]; mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed'; }; gitea: { @@ -99,6 +100,9 @@ function parseEnvConfig(): EnvConfig { const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS ? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean) : undefined; + const starredLists = process.env.MIRROR_STARRED_LISTS + ? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean) + : undefined; return { github: { @@ -117,6 +121,7 @@ function parseEnvConfig(): EnvConfig { autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true', starredReposOrg: process.env.STARRED_REPOS_ORG, starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner', + starredLists, mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed', }, gitea: { @@ -267,6 +272,7 @@ export async function initializeConfigFromEnv(): Promise { defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors', starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false, autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false, + starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [], }; // Build Gitea config diff --git a/src/lib/github-star-lists.test.ts b/src/lib/github-star-lists.test.ts new file mode 100644 index 0000000..f7811f1 --- /dev/null +++ b/src/lib/github-star-lists.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, test, mock } from "bun:test"; +import { + getGithubStarredListNames, + getGithubStarredRepositories, +} from "@/lib/github"; + +function makeRestStarredRepo(overrides: Record = {}) { + return { + name: "demo", + full_name: "acme/demo", + html_url: "https://github.com/acme/demo", + clone_url: "https://github.com/acme/demo.git", + owner: { + login: "acme", + type: "Organization", + }, + private: false, + fork: false, + has_issues: true, + archived: false, + size: 123, + language: "TypeScript", + description: "Demo", + default_branch: "main", + visibility: "public", + disabled: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + ...overrides, + }; +} + +function makeGraphqlListRepo( + nameWithOwner: string, + overrides: Record = {}, +) { + const [owner, name] = nameWithOwner.split("/"); + return { + __typename: "Repository" as const, + name, + nameWithOwner, + url: `https://github.com/${nameWithOwner}`, + sshUrl: `git@github.com:${nameWithOwner}.git`, + isPrivate: false, + isFork: false, + isArchived: false, + isDisabled: false, + hasIssuesEnabled: true, + diskUsage: 456, + description: `${name} repo`, + defaultBranchRef: { name: "main" }, + visibility: "PUBLIC" as const, + updatedAt: "2024-01-02T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + owner: { + __typename: "Organization" as const, + login: owner, + }, + primaryLanguage: { name: "TypeScript" }, + ...overrides, + }; +} + +describe("GitHub starred lists support", () => { + test("falls back to REST starred endpoint when no lists are configured", async () => { + const paginate = mock(async () => [makeRestStarredRepo()]); + const graphql = mock(async () => { + throw new Error("GraphQL should not be used in REST fallback path"); + }); + + const octokit = { + paginate, + graphql, + activity: { + listReposStarredByAuthenticatedUser: () => {}, + }, + } as any; + + const repos = await getGithubStarredRepositories({ + octokit, + config: { githubConfig: { starredLists: [] } } as any, + }); + + expect(repos).toHaveLength(1); + expect(repos[0].fullName).toBe("acme/demo"); + expect(repos[0].isStarred).toBe(true); + expect(paginate).toHaveBeenCalledTimes(1); + expect(graphql).toHaveBeenCalledTimes(0); + }); + + test("filters starred repositories by configured list names and de-duplicates", async () => { + const paginate = mock(async () => []); + const graphql = mock(async (_query: string, variables?: Record) => { + if (!variables || !("listId" in variables)) { + return { + viewer: { + lists: { + nodes: [ + null, + { id: "list-1", name: "HomeLab" }, + { id: "list-2", name: "DotTools" }, + { id: "list-3", name: "Ideas" }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + + if (variables.listId === "list-1") { + return { + node: { + items: { + nodes: [ + null, + makeGraphqlListRepo("acme/repo-a"), + makeGraphqlListRepo("acme/repo-b"), + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + + return { + node: { + items: { + nodes: [ + makeGraphqlListRepo("acme/repo-b"), + makeGraphqlListRepo("acme/repo-c"), + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + }); + + const octokit = { + paginate, + graphql, + activity: { + listReposStarredByAuthenticatedUser: () => {}, + }, + } as any; + + const repos = await getGithubStarredRepositories({ + octokit, + config: { + githubConfig: { + starredLists: ["homelab", "dottools"], + }, + } as any, + }); + + expect(repos).toHaveLength(3); + expect(repos.map((repo) => repo.fullName).sort()).toEqual([ + "acme/repo-a", + "acme/repo-b", + "acme/repo-c", + ]); + expect(paginate).toHaveBeenCalledTimes(0); + }); + + test("matches configured list names even when separators differ", async () => { + const paginate = mock(async () => []); + const graphql = mock(async (_query: string, variables?: Record) => { + if (!variables || !("listId" in variables)) { + return { + viewer: { + lists: { + nodes: [ + { id: "list-1", name: "UI Frontend" }, + { id: "list-2", name: "Email | Self - Hosted" }, + { id: "list-3", name: "PaaS | Hosting | Deploy" }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + + if (variables.listId === "list-1") { + return { + node: { + items: { + nodes: [makeGraphqlListRepo("acme/ui-app")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + + if (variables.listId === "list-2") { + return { + node: { + items: { + nodes: [makeGraphqlListRepo("acme/email-app")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + + return { + node: { + items: { + nodes: [makeGraphqlListRepo("acme/paas-app")], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + }); + + const octokit = { + paginate, + graphql, + activity: { + listReposStarredByAuthenticatedUser: () => {}, + }, + } as any; + + const repos = await getGithubStarredRepositories({ + octokit, + config: { + githubConfig: { + starredLists: ["ui-frontend", "email-self-hosted", "paas-hosting-deploy"], + }, + } as any, + }); + + expect(repos).toHaveLength(3); + expect(repos.map((repo) => repo.fullName).sort()).toEqual([ + "acme/email-app", + "acme/paas-app", + "acme/ui-app", + ]); + expect(paginate).toHaveBeenCalledTimes(0); + }); + + test("throws when configured star list names do not match any GitHub list", async () => { + const paginate = mock(async () => []); + const graphql = mock(async (_query: string, variables?: Record) => { + if (!variables || !("listId" in variables)) { + return { + viewer: { + lists: { + nodes: [{ id: "list-1", name: "HomeLab" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + + return { + node: { + items: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + }); + + const octokit = { + paginate, + graphql, + activity: { + listReposStarredByAuthenticatedUser: () => {}, + }, + } as any; + + await expect( + getGithubStarredRepositories({ + octokit, + config: { + githubConfig: { + starredLists: ["MissingList"], + }, + } as any, + }), + ).rejects.toThrow("Configured GitHub star lists not found"); + expect(paginate).toHaveBeenCalledTimes(0); + }); + + test("returns all available starred list names with pagination", async () => { + const graphql = mock(async (_query: string, variables?: Record) => { + if (!variables?.after) { + return { + viewer: { + lists: { + nodes: [ + null, + { id: "a", name: "HomeLab" }, + { id: "b", name: "DotTools" }, + ], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }; + } + + return { + viewer: { + lists: { + nodes: [ + { id: "c", name: "Ideas" }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + }); + + const octokit = { graphql } as any; + const lists = await getGithubStarredListNames({ octokit }); + expect(lists).toEqual(["HomeLab", "DotTools", "Ideas"]); + expect(graphql).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/github.ts b/src/lib/github.ts index 8753fcb..dc3db64 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -300,6 +300,239 @@ export async function getGithubRepositories({ } } +function getStarredListMatchKey(rawValue: string): string { + const normalized = rawValue.normalize("NFKC").trim().toLowerCase(); + const tokens = normalized.match(/[\p{L}\p{N}]+/gu); + return tokens ? tokens.join("") : ""; +} + +function normalizeStarredListNames(rawLists: unknown): string[] { + if (!Array.isArray(rawLists)) return []; + + const deduped = new Map(); + for (const value of rawLists) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (!trimmed) continue; + const matchKey = getStarredListMatchKey(trimmed); + if (!matchKey || deduped.has(matchKey)) continue; + deduped.set(matchKey, trimmed); + } + + return [...deduped.values()]; +} + +function toHttpsCloneUrl(repoUrl: string): string { + return repoUrl.endsWith(".git") ? repoUrl : `${repoUrl}.git`; +} + +interface GitHubStarListNode { + id: string; + name: string; +} + +interface GitHubRepositoryListItem { + __typename: "Repository"; + name: string; + nameWithOwner: string; + url: string; + sshUrl: string; + isPrivate: boolean; + isFork: boolean; + isArchived: boolean; + isDisabled: boolean; + hasIssuesEnabled: boolean; + diskUsage: number; + description: string | null; + defaultBranchRef: { name: string } | null; + visibility: "PUBLIC" | "PRIVATE" | "INTERNAL"; + updatedAt: string; + createdAt: string; + owner: { + __typename: "Organization" | "User" | string; + login: string; + }; + primaryLanguage: { + name: string; + } | null; +} + +async function getGithubStarLists(octokit: Octokit): Promise { + const allLists: GitHubStarListNode[] = []; + let cursor: string | null = null; + + do { + const result = await octokit.graphql<{ + viewer: { + lists: { + nodes: Array | null; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + }; + }; + }>( + ` + query($after: String) { + viewer { + lists(first: 50, after: $after) { + nodes { + id + name + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + { after: cursor }, + ); + + const lists = (result.viewer.lists.nodes ?? []).filter( + (list): list is GitHubStarListNode => + !!list && + typeof list.id === "string" && + typeof list.name === "string", + ); + allLists.push(...lists); + + if (!result.viewer.lists.pageInfo.hasNextPage) break; + cursor = result.viewer.lists.pageInfo.endCursor; + } while (cursor); + + return allLists; +} + +async function getGithubRepositoriesForStarList( + octokit: Octokit, + listId: string, +): Promise { + const repositories: GitHubRepositoryListItem[] = []; + let cursor: string | null = null; + + do { + const result = await octokit.graphql<{ + node: { + items: { + nodes: Array | null; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + }; + } | null; + }>( + ` + query($listId: ID!, $after: String) { + node(id: $listId) { + ... on UserList { + items(first: 100, after: $after) { + nodes { + __typename + ... on Repository { + name + nameWithOwner + url + sshUrl + isPrivate + isFork + isArchived + isDisabled + hasIssuesEnabled + diskUsage + description + defaultBranchRef { + name + } + visibility + updatedAt + createdAt + owner { + __typename + login + } + primaryLanguage { + name + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + `, + { listId, after: cursor }, + ); + + const listNode = result.node; + if (!listNode) break; + + const nodes = listNode.items.nodes ?? []; + for (const node of nodes) { + if (node?.__typename === "Repository") { + repositories.push(node); + } + } + + if (!listNode.items.pageInfo.hasNextPage) break; + cursor = listNode.items.pageInfo.endCursor; + } while (cursor); + + return repositories; +} + +function mapGraphqlRepoToGitRepo(repo: GitHubRepositoryListItem): GitRepo { + const visibility = (repo.visibility ?? "PUBLIC").toLowerCase() as GitRepo["visibility"]; + const createdAt = repo.createdAt ? new Date(repo.createdAt) : new Date(); + const updatedAt = repo.updatedAt ? new Date(repo.updatedAt) : new Date(); + + return { + name: repo.name, + fullName: repo.nameWithOwner, + url: repo.url, + cloneUrl: toHttpsCloneUrl(repo.url), + + owner: repo.owner.login, + organization: repo.owner.__typename === "Organization" ? repo.owner.login : undefined, + mirroredLocation: "", + destinationOrg: null, + + isPrivate: repo.isPrivate, + isForked: repo.isFork, + forkedFrom: undefined, + + hasIssues: repo.hasIssuesEnabled, + isStarred: true, + isArchived: repo.isArchived, + + size: repo.diskUsage ?? 0, + hasLFS: false, + hasSubmodules: false, + + language: repo.primaryLanguage?.name ?? null, + description: repo.description, + defaultBranch: repo.defaultBranchRef?.name || "main", + visibility, + + status: "imported", + isDisabled: repo.isDisabled, + lastMirrored: undefined, + errorMessage: undefined, + + importedAt: new Date(), + createdAt, + updatedAt, + }; +} + export async function getGithubStarredRepositories({ octokit, config, @@ -308,6 +541,46 @@ export async function getGithubStarredRepositories({ config: Partial; }): Promise { try { + const configuredLists = normalizeStarredListNames( + config.githubConfig?.starredLists, + ); + + if (configuredLists.length > 0) { + const allLists = await getGithubStarLists(octokit); + const configuredMatchKeySet = new Set( + configuredLists.map((list) => getStarredListMatchKey(list)), + ); + + const matchedLists = allLists.filter((list) => + configuredMatchKeySet.has(getStarredListMatchKey(list.name)), + ); + + if (matchedLists.length === 0) { + const availableListNames = normalizeStarredListNames( + allLists.map((list) => list.name), + ); + const preview = availableListNames.slice(0, 20).join(", "); + const availableSuffix = preview + ? `. Available lists: ${preview}${availableListNames.length > 20 ? ", ..." : ""}` + : ""; + throw new Error( + `Configured GitHub star lists not found: ${configuredLists.join(", ")}${availableSuffix}`, + ); + } + + const deduped = new Map(); + for (const list of matchedLists) { + const repos = await getGithubRepositoriesForStarList(octokit, list.id); + for (const repo of repos) { + const key = repo.nameWithOwner.toLowerCase(); + if (deduped.has(key)) continue; + deduped.set(key, mapGraphqlRepoToGitRepo(repo)); + } + } + + return [...deduped.values()]; + } + const starredRepos = await octokit.paginate( octokit.activity.listReposStarredByAuthenticatedUser, { @@ -362,6 +635,15 @@ export async function getGithubStarredRepositories({ } } +export async function getGithubStarredListNames({ + octokit, +}: { + octokit: Octokit; +}): Promise { + const lists = await getGithubStarLists(octokit); + return normalizeStarredListNames(lists.map((list) => list.name)); +} + /** * Get user github organizations */ diff --git a/src/lib/utils/config-defaults.ts b/src/lib/utils/config-defaults.ts index b6c6503..3af6be7 100644 --- a/src/lib/utils/config-defaults.ts +++ b/src/lib/utils/config-defaults.ts @@ -85,6 +85,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default includeOrganizations: [], starredReposOrg: "starred", starredReposMode: "dedicated-org", + starredLists: [], mirrorStrategy: "preserve", defaultOrg: "github-mirrors", }, diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index 933c1b6..de4d178 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -20,6 +20,17 @@ type DbGiteaConfig = z.infer; type DbScheduleConfig = z.infer; type DbCleanupConfig = z.infer; +function normalizeStarredLists(lists: string[] | undefined): string[] { + if (!Array.isArray(lists)) return []; + const deduped = new Set(); + for (const list of lists) { + const trimmed = list.trim(); + if (!trimmed) continue; + deduped.add(trimmed); + } + return [...deduped]; +} + /** * Maps UI config structure to database schema structure */ @@ -50,6 +61,7 @@ export function mapUiToDbConfig( // Starred repos organization starredReposOrg: giteaConfig.starredReposOrg, starredReposMode: giteaConfig.starredReposMode || "dedicated-org", + starredLists: normalizeStarredLists(githubConfig.starredLists), // Mirror strategy mirrorStrategy: giteaConfig.mirrorStrategy || "preserve", @@ -131,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): { token: dbConfig.githubConfig?.token || "", privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred + starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists), }; // Map from database Gitea config to UI fields diff --git a/src/pages/api/github/starred-lists.ts b/src/pages/api/github/starred-lists.ts new file mode 100644 index 0000000..40387c2 --- /dev/null +++ b/src/pages/api/github/starred-lists.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from "astro"; +import { db, configs } from "@/lib/db"; +import { eq } from "drizzle-orm"; +import { + createGitHubClient, + getGithubStarredListNames, +} from "@/lib/github"; +import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption"; +import { jsonResponse, createSecureErrorResponse } from "@/lib/utils"; +import { requireAuthenticatedUserId } from "@/lib/auth-guards"; + +export const GET: APIRoute = async ({ request, locals }) => { + try { + const authResult = await requireAuthenticatedUserId({ request, locals }); + if ("response" in authResult) return authResult.response; + const userId = authResult.userId; + + const [config] = await db + .select() + .from(configs) + .where(eq(configs.userId, userId)) + .limit(1); + + if (!config) { + return jsonResponse({ + data: { success: false, message: "No configuration found for this user" }, + status: 404, + }); + } + + if (!config.githubConfig?.token) { + return jsonResponse({ + data: { success: false, message: "GitHub token is missing in config" }, + status: 400, + }); + } + + const token = getDecryptedGitHubToken(config); + const githubUsername = config.githubConfig?.owner || undefined; + const octokit = createGitHubClient(token, userId, githubUsername); + const lists = await getGithubStarredListNames({ octokit }); + + return jsonResponse({ + data: { + success: true, + lists, + }, + status: 200, + }); + } catch (error) { + return createSecureErrorResponse(error, "starred lists fetch", 500); + } +}; diff --git a/src/types/config.ts b/src/types/config.ts index acefcbf..47bc71a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -61,6 +61,7 @@ export interface GitHubConfig { token: string; privateRepositories: boolean; mirrorStarred: boolean; + starredLists?: string[]; starredDuplicateStrategy?: DuplicateNameStrategy; starredReposMode?: StarredReposMode; }