initial commit
This commit is contained in:
391
src/app/services/githubStarLists.ts
Normal file
391
src/app/services/githubStarLists.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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<string, Set<string>>;
|
||||
failedListIds: string[];
|
||||
};
|
||||
|
||||
type ListApiField = "lists" | "userLists";
|
||||
|
||||
type GraphqlPageInfo = {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
|
||||
type StarListConnection = {
|
||||
nodes: StarList[];
|
||||
pageInfo: GraphqlPageInfo;
|
||||
};
|
||||
|
||||
type FetchStarListsResponse = {
|
||||
viewer: Record<ListApiField, StarListConnection>;
|
||||
};
|
||||
|
||||
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<ListApiField> {
|
||||
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<StarList[]> {
|
||||
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<StarredRepo[]> {
|
||||
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<string> {
|
||||
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<StarListMembership> {
|
||||
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<UserListRef> {
|
||||
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<RepositoryMeta | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>,
|
||||
onProgress?: (progress: RepoMembershipIndexProgress) => void
|
||||
): Promise<RepoMembershipIndexResult> {
|
||||
const index = new Map<string, Set<string>>();
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user