Files
TanTan f954252a34
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run
NapCatQQ source snapshot
2026-11-13 09:01:47 +08:00

322 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GitHub API 工具库
*/
import { appendFileSync } from 'node:fs';
// ============== 类型定义 ==============
export interface PullRequest {
number: number;
state: string;
head: {
sha: string;
ref: string;
repo: {
full_name: string;
};
};
}
export interface Repository {
owner: {
type: string;
};
}
export interface Artifact {
id: number;
name: string;
size_in_bytes: number;
archive_download_url: string;
}
export interface Release {
id: number;
tag_name: string;
name: string;
upload_url: string;
html_url: string;
created_at: string;
}
export interface ReleaseAsset {
id: number;
name: string;
browser_download_url: string;
}
// ============== GitHub API Client ==========================
export class GitHubAPI {
private token: string;
private baseUrl = 'https://api.github.com';
constructor (token: string) {
this.token = token;
}
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
// 204 No Content(DELETE 等)不解析 body
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
}
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
const data = await this.request<{ permission: string; }>(
`/repos/${owner}/${repo}/collaborators/${username}/permission`
);
return data.permission;
}
async getRepository (owner: string, repo: string): Promise<Repository> {
return this.request(`/repos/${owner}/${repo}`);
}
async checkOrgMembership (org: string, username: string): Promise<boolean> {
try {
await this.request(`/orgs/${org}/members/${username}`);
return true;
} catch {
return false;
}
}
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
const data = await this.request<{ artifacts: Artifact[]; }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
);
return data.artifacts;
}
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
let page = 1;
const perPage = 100;
while (page <= 10) { // 最多检查 1000 条评论
const comments = await this.request<Array<{ id: number, body: string; }>>(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
);
if (comments.length === 0) {
return null;
}
const found = comments.find(c => c.body.includes(marker));
if (found) {
return found.id;
}
if (comments.length < perPage) {
return null;
}
page++;
}
return null;
}
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async createOrUpdateComment (
owner: string,
repo: string,
issueNumber: number,
body: string,
marker: string
): Promise<void> {
const existingId = await this.findComment(owner, repo, issueNumber, marker);
if (existingId) {
await this.updateComment(owner, repo, existingId, body);
console.log(`✓ Updated comment #${existingId}`);
} else {
await this.createComment(owner, repo, issueNumber, body);
console.log('✓ Created new comment');
}
}
// ============== Release API ==============
async getReleaseByTag (owner: string, repo: string, tag: string): Promise<Release | null> {
try {
return await this.request<Release>(`/repos/${owner}/${repo}/releases/tags/${tag}`);
} catch {
return null;
}
}
async deleteRelease (owner: string, repo: string, releaseId: number): Promise<void> {
await this.request(`/repos/${owner}/${repo}/releases/${releaseId}`, { method: 'DELETE' });
}
async deleteRef (owner: string, repo: string, ref: string): Promise<void> {
try {
await this.request(`/repos/${owner}/${repo}/git/refs/${ref}`, { method: 'DELETE' });
} catch {
// ref may not exist, ignore
}
}
async createRelease (
owner: string,
repo: string,
tag: string,
name: string,
body: string,
prerelease = true
): Promise<Release> {
return this.request<Release>(`/repos/${owner}/${repo}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name: tag, name, body, prerelease }),
headers: { 'Content-Type': 'application/json' },
});
}
async uploadReleaseAsset (
uploadUrl: string,
fileName: string,
data: Buffer,
contentType = 'application/octet-stream'
): Promise<ReleaseAsset> {
const url = uploadUrl.replace(/\{[^}]*\}/g, '') + `?name=${encodeURIComponent(fileName)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'Content-Type': contentType,
'X-GitHub-Api-Version': '2022-11-28',
},
body: data,
});
if (!response.ok) {
throw new Error(`Upload ${fileName} failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<ReleaseAsset>;
}
// ============== Dispatch API ==============
async repositoryDispatch (
owner: string,
repo: string,
eventType: string,
clientPayload: Record<string, string>
): Promise<void> {
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ event_type: eventType, client_payload: clientPayload }),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Dispatch failed: ${response.status} ${response.statusText}`);
}
}
async createWorkflowDispatch (
owner: string,
repo: string,
workflowFile: string,
ref: string,
inputs: Record<string, string>
): Promise<void> {
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/actions/workflows/${workflowFile}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ref, inputs }),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Workflow dispatch failed: ${response.status} ${response.statusText}`);
}
}
// ============== Release Cleanup API ==============
async listReleases (owner: string, repo: string, perPage = 100, page = 1): Promise<Release[]> {
return this.request<Release[]>(
`/repos/${owner}/${repo}/releases?per_page=${perPage}&page=${page}`
);
}
}
// ============== Output 工具 ==============
export function setOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
}
console.log(` ${name}=${value}`);
}
export function setMultilineOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `EOF_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
// ============== 环境变量工具 ==============
// Note: 不使用函数重载——Node.js --experimental-strip-types 不支持 TS overload signatures
export function getEnv (name: string, required = false): string | undefined {
const value = process.env[name];
if (required && !value) {
throw new Error(`Environment variable ${name} is required`);
}
return value;
}
export function getRepository (): { owner: string, repo: string; } {
const repository = getEnv('GITHUB_REPOSITORY', true)!;
const [owner, repo] = repository.split('/');
return { owner, repo };
}