Add GitHub starred-list filtering with searchable selector (#247)

* feat: add starred list filtering and selector UI

* docs: add starred lists UI screenshot

* lib: improve starred list name matching
This commit is contained in:
ARUNAVO RAY
2026-03-24 07:33:46 +05:30
committed by GitHub
parent 95e6eb7602
commit 6f2e0cbca0
14 changed files with 934 additions and 3 deletions

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View 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);
});
});

View File

@@ -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
*/

View File

@@ -85,6 +85,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
includeOrganizations: [],
starredReposOrg: "starred",
starredReposMode: "dedicated-org",
starredLists: [],
mirrorStrategy: "preserve",
defaultOrg: "github-mirrors",
},

View File

@@ -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