mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-24 22:58:03 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32eb27c8a6 | ||
|
|
d33b4ff64f | ||
|
|
6f2e0cbca0 | ||
|
|
95e6eb7602 |
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
|
||||
BIN
docs/images/starred-lists-ui.png
Normal file
BIN
docs/images/starred-lists-ui.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.14.0",
|
||||
"version": "3.14.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ConfigTabs() {
|
||||
token: '',
|
||||
privateRepositories: false,
|
||||
mirrorStarred: false,
|
||||
starredLists: [],
|
||||
},
|
||||
giteaConfig: {
|
||||
url: '',
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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<string>();
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
@@ -312,6 +423,143 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star list selection */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Star Lists (optional)
|
||||
</Label>
|
||||
<Popover open={starListsOpen} onOpenChange={setStarListsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={starListsOpen}
|
||||
className="w-full justify-between h-9 text-xs font-normal"
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedStarLists.length === 0
|
||||
? "All starred repositories"
|
||||
: `${selectedStarLists.length} list${selectedStarLists.length === 1 ? "" : "s"} selected`}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[360px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
value={starListSearch}
|
||||
onValueChange={setStarListSearch}
|
||||
placeholder="Search GitHub star lists..."
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loadingStarLists ? "Loading star lists..." : "No matching lists"}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allKnownStarLists.map((list) => {
|
||||
const isSelected = selectedStarLists.some(
|
||||
(selected) => selected.toLowerCase() === list.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={list}
|
||||
value={list}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setSelectedStarLists([...selectedStarLists, list]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{list}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
{canAddSearchAsStarList && (
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => {
|
||||
setSelectedStarLists([...selectedStarLists, normalizedStarListSearch]);
|
||||
setStarListSearch("");
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add "{normalizedStarListSearch}"
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to mirror all starred repositories. Select one or more lists to limit syncing.
|
||||
</p>
|
||||
|
||||
{selectedStarLists.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedStarLists.map((list) => (
|
||||
<Badge key={list} variant="secondary" className="gap-1">
|
||||
<span>{list}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedStarLists(
|
||||
selectedStarLists.filter(
|
||||
(selected) => selected.toLowerCase() !== list.toLowerCase(),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="rounded-sm hover:text-foreground/80"
|
||||
aria-label={`Remove ${list} list`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customStarListName}
|
||||
onChange={(event) => setCustomStarListName(event.target.value)}
|
||||
placeholder="Add custom list name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addCustomStarList}
|
||||
disabled={!customStarListName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duplicate name handling for starred repos */}
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="mt-4 space-y-2">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
319
src/lib/github-star-lists.test.ts
Normal file
319
src/lib/github-star-lists.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, test, mock } from "bun:test";
|
||||
import {
|
||||
getGithubStarredListNames,
|
||||
getGithubStarredRepositories,
|
||||
} from "@/lib/github";
|
||||
|
||||
function makeRestStarredRepo(overrides: Record<string, unknown> = {}) {
|
||||
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<string, unknown> = {},
|
||||
) {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
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<GitHubStarListNode[]> {
|
||||
const allLists: GitHubStarListNode[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const result = await octokit.graphql<{
|
||||
viewer: {
|
||||
lists: {
|
||||
nodes: Array<GitHubStarListNode | null> | 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<GitHubRepositoryListItem[]> {
|
||||
const repositories: GitHubRepositoryListItem[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const result = await octokit.graphql<{
|
||||
node: {
|
||||
items: {
|
||||
nodes: Array<GitHubRepositoryListItem | null> | 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<Config>;
|
||||
}): Promise<GitRepo[]> {
|
||||
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<string, GitRepo>();
|
||||
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<string[]> {
|
||||
const lists = await getGithubStarLists(octokit);
|
||||
return normalizeStarredListNames(lists.map((list) => list.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user github organizations
|
||||
*/
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
includeOrganizations: [],
|
||||
starredReposOrg: "starred",
|
||||
starredReposMode: "dedicated-org",
|
||||
starredLists: [],
|
||||
mirrorStrategy: "preserve",
|
||||
defaultOrg: "github-mirrors",
|
||||
},
|
||||
|
||||
@@ -20,6 +20,17 @@ type DbGiteaConfig = z.infer<typeof giteaConfigSchema>;
|
||||
type DbScheduleConfig = z.infer<typeof scheduleConfigSchema>;
|
||||
type DbCleanupConfig = z.infer<typeof cleanupConfigSchema>;
|
||||
|
||||
function normalizeStarredLists(lists: string[] | undefined): string[] {
|
||||
if (!Array.isArray(lists)) return [];
|
||||
const deduped = new Set<string>();
|
||||
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
|
||||
|
||||
53
src/pages/api/github/starred-lists.ts
Normal file
53
src/pages/api/github/starred-lists.ts
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -61,6 +61,7 @@ export interface GitHubConfig {
|
||||
token: string;
|
||||
privateRepositories: boolean;
|
||||
mirrorStarred: boolean;
|
||||
starredLists?: string[];
|
||||
starredDuplicateStrategy?: DuplicateNameStrategy;
|
||||
starredReposMode?: StarredReposMode;
|
||||
}
|
||||
|
||||
8
www/pnpm-lock.yaml
generated
8
www/pnpm-lock.yaml
generated
@@ -1199,8 +1199,8 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
h3@1.15.5:
|
||||
resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==}
|
||||
h3@1.15.9:
|
||||
resolution: {integrity: sha512-H7UPnyIupUOYUQu7f2x7ABVeMyF/IbJjqn20WSXpMdnQB260luADUkSgJU7QTWLutq8h3tUayMQ1DdbSYX5LkA==}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
|
||||
@@ -3204,7 +3204,7 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
h3@1.15.5:
|
||||
h3@1.15.9:
|
||||
dependencies:
|
||||
cookie-es: 1.2.2
|
||||
crossws: 0.3.5
|
||||
@@ -4389,7 +4389,7 @@ snapshots:
|
||||
anymatch: 3.1.3
|
||||
chokidar: 5.0.0
|
||||
destr: 2.0.5
|
||||
h3: 1.15.5
|
||||
h3: 1.15.9
|
||||
lru-cache: 11.2.6
|
||||
node-fetch-native: 1.6.7
|
||||
ofetch: 1.5.1
|
||||
|
||||
Reference in New Issue
Block a user