import { ghGraphql, ghRest, type GitHubConfig } from "./githubClient"; export type StarList = { id: string; name: string; description: string; }; export type StarredRepo = { id: string; name: string; fullName: string; description: string | null; url: string; topics: string[]; language: string | null; updatedAt: string; stargazerCount: number; }; export type StarListMembership = { listId: string; repoIds: string[]; }; export type UserListRef = { id: string; name: string; }; export type RepositoryMeta = { id: string; viewerHasStarred: boolean; }; export type RepoMembershipIndexProgress = { current: number; total: number; }; export type RepoMembershipIndexResult = { index: Map>; failedListIds: string[]; }; type ListApiField = "lists" | "userLists"; type GraphqlPageInfo = { hasNextPage: boolean; endCursor: string | null; }; type StarListConnection = { nodes: StarList[]; pageInfo: GraphqlPageInfo; }; type FetchStarListsResponse = { viewer: Record; }; type StarredRepoNode = { id: string; name: string; nameWithOwner: string; description: string | null; url: string; repositoryTopics: { nodes: { topic: { name: string } }[] }; primaryLanguage: { name: string } | null; updatedAt: string; stargazerCount: number; }; type FetchStarredReposResponse = { viewer: { starredRepositories: { nodes: StarredRepoNode[]; pageInfo: GraphqlPageInfo; }; }; }; type FetchListMembershipResponse = { node: { items: { nodes: { id: string }[]; pageInfo: GraphqlPageInfo; }; } | null; }; type CreateUserListResponse = { createUserList: { list: UserListRef | null; } | null; }; type RepositoryMetaResponse = { repository: RepositoryMeta | null; }; export async function detectStarListApi(config: GitHubConfig): Promise { const candidates: ListApiField[] = ["lists", "userLists"]; for (const field of candidates) { try { const query = `query { viewer { ${field}(first: 1) { totalCount } } }`; await ghGraphql(config, query); return field; } catch { continue; } } throw new Error("Star Lists API not available for this token/account"); } export async function fetchStarLists( config: GitHubConfig, field: ListApiField ): Promise { const lists: StarList[] = []; let after: string | null = null; while (true) { const query = ` query ($first: Int!, $after: String) { viewer { ${field}(first: $first, after: $after) { nodes { id name description } pageInfo { hasNextPage endCursor } } } } `; const data: FetchStarListsResponse = await ghGraphql( config, query, { first: 100, after } ); const connection: StarListConnection = data.viewer[field]; lists.push(...(connection.nodes ?? [])); if (!connection.pageInfo.hasNextPage) break; after = connection.pageInfo.endCursor; } return lists; } export async function fetchStarredRepos(config: GitHubConfig): Promise { const repos: StarredRepo[] = []; let after: string | null = null; while (true) { const query = ` query ($first: Int!, $after: String) { viewer { starredRepositories(first: $first, after: $after, orderBy: { field: STARRED_AT, direction: DESC }) { nodes { id name nameWithOwner description url repositoryTopics(first: 10) { nodes { topic { name } } } primaryLanguage { name } updatedAt stargazerCount } pageInfo { hasNextPage endCursor } } } } `; const data: FetchStarredReposResponse = await ghGraphql(config, query, { first: 100, after }); const connection: FetchStarredReposResponse["viewer"]["starredRepositories"] = data.viewer.starredRepositories; repos.push( ...connection.nodes.map((node: StarredRepoNode) => ({ id: node.id, name: node.name, fullName: node.nameWithOwner, description: node.description, url: node.url, topics: node.repositoryTopics.nodes.map( (topicNode: { topic: { name: string } }) => topicNode.topic.name ), language: node.primaryLanguage?.name ?? null, updatedAt: node.updatedAt, stargazerCount: node.stargazerCount, })) ); if (!connection.pageInfo.hasNextPage) break; after = connection.pageInfo.endCursor; } return repos; } export async function fetchReadmeExcerpt( config: GitHubConfig, owner: string, repo: string ): Promise { const data = await ghRest<{ content?: string; encoding?: string }>( config, `/repos/${owner}/${repo}/readme` ); const content = data.content ?? ""; const encoding = data.encoding ?? "base64"; if (encoding !== "base64") { return ""; } const decoded = atob(content.replace(/\n/g, "")); return decoded.slice(0, 3000); } export async function fetchListMembership( config: GitHubConfig, listId: string ): Promise { const repoIds: string[] = []; let after: string | null = null; while (true) { const query = ` query ($id: ID!, $first: Int!, $after: String) { node(id: $id) { ... on UserList { items(first: $first, after: $after) { nodes { ... on Repository { id } } pageInfo { hasNextPage endCursor } } } } } `; const data: FetchListMembershipResponse = await ghGraphql(config, query, { id: listId, first: 100, after, }); if (!data.node?.items) break; repoIds.push(...data.node.items.nodes.map((node: { id: string }) => node.id)); if (!data.node.items.pageInfo.hasNextPage) break; after = data.node.items.pageInfo.endCursor; } return { listId, repoIds }; } export async function createUserList( config: GitHubConfig, name: string, isPrivate: boolean, description = "" ): Promise { const query = ` mutation($input: CreateUserListInput!) { createUserList(input: $input) { list { id name } } } `; const input: { name: string; isPrivate: boolean; description?: string } = { name, isPrivate }; if (description.trim()) { input.description = description.trim(); } const data: CreateUserListResponse = await ghGraphql(config, query, { input }); const created = data.createUserList?.list; if (!created) { throw new Error(`Failed to create list: ${name}`); } return created; } export async function getRepositoryMeta( config: GitHubConfig, owner: string, name: string ): Promise { const query = ` query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id viewerHasStarred } } `; try { const data: RepositoryMetaResponse = await ghGraphql(config, query, { owner, name }); return data.repository; } catch (error) { const message = (error as Error).message || ""; if (message.includes("Could not resolve to a Repository")) { return null; } throw error; } } export async function addStar(config: GitHubConfig, starrableId: string): Promise { const query = ` mutation($input: AddStarInput!) { addStar(input: $input) { starrable { id } } } `; await ghGraphql(config, query, { input: { starrableId } }); } export async function updateUserListsForItem( config: GitHubConfig, itemId: string, listIds: string[] ): Promise { const query = ` mutation($input: UpdateUserListsForItemInput!) { updateUserListsForItem(input: $input) { clientMutationId } } `; await ghGraphql(config, query, { input: { itemId, listIds } }); } export async function buildRepoMembershipIndex( config: GitHubConfig, listIds: string[], targetRepoIds: Set, onProgress?: (progress: RepoMembershipIndexProgress) => void ): Promise { const index = new Map>(); const failedListIds: string[] = []; const total = listIds.length; let current = 0; for (const listId of listIds) { try { let after: string | null = null; while (true) { const query = ` query($id: ID!, $first: Int!, $after: String) { node(id: $id) { ... on UserList { items(first: $first, after: $after) { nodes { ... on Repository { id } } pageInfo { hasNextPage endCursor } } } } } `; const data: FetchListMembershipResponse = await ghGraphql(config, query, { id: listId, first: 100, after, }); const items = data.node?.items; if (!items) break; for (const node of items.nodes) { if (!targetRepoIds.has(node.id)) continue; const existing = index.get(node.id) ?? new Set(); existing.add(listId); index.set(node.id, existing); } if (!items.pageInfo.hasNextPage) break; after = items.pageInfo.endCursor; } } catch { failedListIds.push(listId); } current += 1; onProgress?.({ current, total }); } return { index, failedListIds }; }