mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
* add issue triage automation mvp * Document issue automation design in Chinese * Fix legacy compat slug tests
230 lines
5.3 KiB
TypeScript
230 lines
5.3 KiB
TypeScript
interface GitHubUser {
|
|
login: string;
|
|
}
|
|
|
|
interface GitHubLabelRef {
|
|
name?: string;
|
|
}
|
|
|
|
export interface GitHubIssue {
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
state: string;
|
|
labels: GitHubLabelRef[];
|
|
comments: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
user: GitHubUser;
|
|
html_url: string;
|
|
pull_request?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface GitHubIssueComment {
|
|
id: number;
|
|
body: string;
|
|
user: GitHubUser;
|
|
created_at: string;
|
|
updated_at: string;
|
|
html_url: string;
|
|
}
|
|
|
|
export interface GitHubLabelDefinition {
|
|
name: string;
|
|
color: string;
|
|
description: string;
|
|
}
|
|
|
|
function buildApiUrl(path: string) {
|
|
return `https://api.github.com${path}`;
|
|
}
|
|
|
|
export class GitHubClient {
|
|
constructor(
|
|
private readonly token: string,
|
|
private readonly owner: string,
|
|
private readonly repo: string,
|
|
) {}
|
|
|
|
async getIssue(issueNumber: number): Promise<GitHubIssue> {
|
|
return this.request<GitHubIssue>(
|
|
"GET",
|
|
`/repos/${this.owner}/${this.repo}/issues/${issueNumber}`,
|
|
);
|
|
}
|
|
|
|
async listIssueComments(issueNumber: number): Promise<GitHubIssueComment[]> {
|
|
return this.paginate<GitHubIssueComment>(
|
|
`/repos/${this.owner}/${this.repo}/issues/${issueNumber}/comments?per_page=100`,
|
|
);
|
|
}
|
|
|
|
async listOpenIssuesByLabel(
|
|
label: string,
|
|
limit = 0,
|
|
): Promise<GitHubIssue[]> {
|
|
const collected: GitHubIssue[] = [];
|
|
const unlimited = limit === 0;
|
|
let page = 1;
|
|
|
|
while (unlimited || collected.length < limit) {
|
|
const pageItems = await this.request<GitHubIssue[]>(
|
|
"GET",
|
|
`/repos/${this.owner}/${this.repo}/issues?state=open&labels=${
|
|
encodeURIComponent(label)
|
|
}&per_page=100&page=${page}`,
|
|
);
|
|
|
|
const nonPrIssues = pageItems.filter((item) => !item.pull_request);
|
|
collected.push(...nonPrIssues);
|
|
|
|
if (pageItems.length < 100) {
|
|
break;
|
|
}
|
|
|
|
page += 1;
|
|
}
|
|
|
|
return unlimited ? collected : collected.slice(0, limit);
|
|
}
|
|
|
|
async replaceIssueLabels(issueNumber: number, labels: string[]) {
|
|
await this.request(
|
|
"PUT",
|
|
`/repos/${this.owner}/${this.repo}/issues/${issueNumber}/labels`,
|
|
{ labels },
|
|
);
|
|
}
|
|
|
|
async upsertLabel(definition: GitHubLabelDefinition) {
|
|
const encodedName = encodeURIComponent(definition.name);
|
|
|
|
try {
|
|
await this.request(
|
|
"PATCH",
|
|
`/repos/${this.owner}/${this.repo}/labels/${encodedName}`,
|
|
{
|
|
new_name: definition.name,
|
|
color: definition.color,
|
|
description: definition.description,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (!(error instanceof GitHubApiError) || error.status !== 404) {
|
|
throw error;
|
|
}
|
|
|
|
await this.request("POST", `/repos/${this.owner}/${this.repo}/labels`, {
|
|
name: definition.name,
|
|
color: definition.color,
|
|
description: definition.description,
|
|
});
|
|
}
|
|
}
|
|
|
|
async createIssueComment(issueNumber: number, body: string) {
|
|
return this.request<GitHubIssueComment>(
|
|
"POST",
|
|
`/repos/${this.owner}/${this.repo}/issues/${issueNumber}/comments`,
|
|
{ body },
|
|
);
|
|
}
|
|
|
|
async updateIssueComment(commentId: number, body: string) {
|
|
return this.request<GitHubIssueComment>(
|
|
"PATCH",
|
|
`/repos/${this.owner}/${this.repo}/issues/comments/${commentId}`,
|
|
{ body },
|
|
);
|
|
}
|
|
|
|
private async paginate<T>(path: string): Promise<T[]> {
|
|
const collected: T[] = [];
|
|
let nextPath: string | null = path;
|
|
|
|
while (nextPath) {
|
|
const response = await fetch(buildApiUrl(nextPath), {
|
|
headers: this.headers(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await GitHubApiError.fromResponse(response);
|
|
}
|
|
|
|
const pageItems = (await response.json()) as T[];
|
|
collected.push(...pageItems);
|
|
nextPath = parseNextLink(response.headers.get("link"));
|
|
}
|
|
|
|
return collected;
|
|
}
|
|
|
|
private async request<T = void>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
const response = await fetch(buildApiUrl(path), {
|
|
method,
|
|
headers: this.headers(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await GitHubApiError.fromResponse(response);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
private headers() {
|
|
return {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${this.token}`,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "skillhub-issue-triage",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
};
|
|
}
|
|
}
|
|
|
|
export class GitHubApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly responseBody: string,
|
|
) {
|
|
super(`GitHub API request failed with status ${status}: ${responseBody}`);
|
|
}
|
|
|
|
static async fromResponse(response: Response) {
|
|
return new GitHubApiError(response.status, await response.text());
|
|
}
|
|
}
|
|
|
|
function parseNextLink(linkHeader: string | null) {
|
|
if (!linkHeader) {
|
|
return null;
|
|
}
|
|
|
|
const nextEntry = linkHeader
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.find((item) => item.endsWith('rel="next"'));
|
|
|
|
if (!nextEntry) {
|
|
return null;
|
|
}
|
|
|
|
const urlMatch = nextEntry.match(/<([^>]+)>/);
|
|
|
|
if (!urlMatch) {
|
|
return null;
|
|
}
|
|
|
|
const url = new URL(urlMatch[1]);
|
|
return `${url.pathname}${url.search}`;
|
|
}
|