254 lines
7.1 KiB
JavaScript
254 lines
7.1 KiB
JavaScript
/**
|
|
* Gitea API Client — 通用项目管理工具
|
|
*
|
|
* 封装所有 Gitea REST API 调用,处理 UTF-8 编码和错误重试。
|
|
* 供 gitea.js CLI 使用。连接信息从 gitea-config.json 读取。
|
|
*/
|
|
|
|
const http = require('http');
|
|
|
|
class GiteaAPI {
|
|
/**
|
|
* @param {string} token - Gitea API Token
|
|
* @param {object} config - 必须包含 host, port, repoOwner, repoName
|
|
*/
|
|
constructor(token, config = {}) {
|
|
if (!token) throw new Error('GITEA_TOKEN 未设置。请设置环境变量 GITEA_TOKEN。');
|
|
this.token = token;
|
|
this.config = {
|
|
apiBase: '/api/v1',
|
|
timeout: 15000,
|
|
...config,
|
|
};
|
|
// 从 config 构建 repoPath
|
|
if (!this.config.repoPath && this.config.repoOwner && this.config.repoName) {
|
|
this.config.repoPath = `/repos/${this.config.repoOwner}/${this.config.repoName}`;
|
|
}
|
|
}
|
|
|
|
// ====== 底层 HTTP 请求 ======
|
|
|
|
/**
|
|
* 发送 API 请求
|
|
* @param {string} method - GET / POST / PATCH / PUT / DELETE
|
|
* @param {string} path - API 路径(相对于 repo root),如 /issues、/milestones/2
|
|
* @param {object|null} data - 请求体(自动 JSON 序列化)
|
|
* @param {object} [params] - URL 查询参数
|
|
* @returns {Promise<any>} 解析后的 JSON 响应
|
|
*/
|
|
async request(method, path, data = null, params = {}) {
|
|
const basePath = this.config.apiBase + this.config.repoPath + path;
|
|
const url = new URL(`http://${this.config.host}:${this.config.port}${basePath}`);
|
|
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v !== undefined && v !== null && v !== '') {
|
|
url.searchParams.set(k, String(v));
|
|
}
|
|
}
|
|
|
|
const body = data ? JSON.stringify(data) : null;
|
|
|
|
const headers = {
|
|
'Authorization': `token ${this.token}`,
|
|
'Accept': 'application/json',
|
|
};
|
|
if (body) {
|
|
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
headers['Content-Length'] = Buffer.byteLength(body, 'utf-8');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
hostname: url.hostname,
|
|
port: url.port || 80,
|
|
path: url.pathname + url.search,
|
|
method: method.toUpperCase(),
|
|
headers,
|
|
};
|
|
|
|
const req = http.request(options, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
if (res.statusCode >= 400) {
|
|
const err = new Error(`HTTP ${res.statusCode}: ${raw.substring(0, 300)}`);
|
|
err.statusCode = res.statusCode;
|
|
err.body = raw;
|
|
reject(err);
|
|
return;
|
|
}
|
|
if (!raw.trim()) { resolve(null); return; }
|
|
try { resolve(JSON.parse(raw)); }
|
|
catch { resolve(raw); }
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.setTimeout(this.config.timeout, () => {
|
|
req.destroy();
|
|
reject(new Error(`请求超时 (${this.config.timeout}ms): ${method} ${path}`));
|
|
});
|
|
|
|
if (body) req.write(body, 'utf-8');
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// ====== Issues ======
|
|
|
|
listIssues(params = {}) {
|
|
return this.request('GET', '/issues', null, { state: 'open', limit: 50, ...params });
|
|
}
|
|
|
|
getIssue(number) {
|
|
return this.request('GET', `/issues/${number}`);
|
|
}
|
|
|
|
createIssue(data) {
|
|
return this.request('POST', '/issues', data);
|
|
}
|
|
|
|
updateIssue(number, data) {
|
|
return this.request('PATCH', `/issues/${number}`, data);
|
|
}
|
|
|
|
closeIssue(number, comment) {
|
|
const p = this.updateIssue(number, { state: 'closed' });
|
|
if (comment) return p.then(() => this.createComment(number, comment));
|
|
return p;
|
|
}
|
|
|
|
reopenIssue(number) {
|
|
return this.updateIssue(number, { state: 'open' });
|
|
}
|
|
|
|
// ====== Comments ======
|
|
|
|
listComments(number) {
|
|
return this.request('GET', `/issues/${number}/comments`);
|
|
}
|
|
|
|
createComment(number, body) {
|
|
return this.request('POST', `/issues/${number}/comments`, { body });
|
|
}
|
|
|
|
// ====== Labels ======
|
|
|
|
listLabels(params = {}) {
|
|
return this.request('GET', '/labels', null, { page: 1, limit: 100, ...params });
|
|
}
|
|
|
|
createLabel(data) {
|
|
return this.request('POST', '/labels', data);
|
|
}
|
|
|
|
getLabel(id) {
|
|
return this.request('GET', `/labels/${id}`);
|
|
}
|
|
|
|
deleteLabel(id) {
|
|
return this.request('DELETE', `/labels/${id}`);
|
|
}
|
|
|
|
getIssueLabels(number) {
|
|
return this.request('GET', `/issues/${number}/labels`);
|
|
}
|
|
|
|
/** 添加标签到 Issue(不清除已有标签) */
|
|
addIssueLabels(number, labelIds) {
|
|
return this.request('POST', `/issues/${number}/labels`, { labels: labelIds });
|
|
}
|
|
|
|
/** 替换 Issue 的全部标签 */
|
|
replaceIssueLabels(number, labelIds) {
|
|
return this.request('PUT', `/issues/${number}/labels`, { labels: labelIds });
|
|
}
|
|
|
|
/** 移除 Issue 的单个标签 */
|
|
removeIssueLabel(number, labelId) {
|
|
return this.request('DELETE', `/issues/${number}/labels/${labelId}`);
|
|
}
|
|
|
|
// ====== Milestones ======
|
|
|
|
listMilestones(params = {}) {
|
|
return this.request('GET', '/milestones', null, { limit: 50, ...params });
|
|
}
|
|
|
|
getMilestone(id) {
|
|
return this.request('GET', `/milestones/${id}`);
|
|
}
|
|
|
|
createMilestone(data) {
|
|
return this.request('POST', '/milestones', data);
|
|
}
|
|
|
|
updateMilestone(id, data) {
|
|
return this.request('PATCH', `/milestones/${id}`, data);
|
|
}
|
|
|
|
closeMilestone(id) {
|
|
return this.updateMilestone(id, { state: 'closed' });
|
|
}
|
|
|
|
// ====== Dependencies ======
|
|
|
|
/** 获取阻塞当前 Issue 的前置任务列表 */
|
|
listDependencies(number) {
|
|
return this.request('GET', `/issues/${number}/dependencies`);
|
|
}
|
|
|
|
/** 获取当前 Issue 阻塞的任务列表 */
|
|
listBlocks(number) {
|
|
return this.request('GET', `/issues/${number}/blocks`);
|
|
}
|
|
|
|
/** 设置依赖:number 阻塞 blockedNumber */
|
|
addBlock(number, blockedNumber) {
|
|
return this.request('POST', `/issues/${number}/blocks`, {
|
|
index: blockedNumber,
|
|
owner: this.config.repoOwner,
|
|
repo: this.config.repoName,
|
|
});
|
|
}
|
|
|
|
/** 移除依赖 */
|
|
removeBlock(number, blockedNumber) {
|
|
return this.request('DELETE', `/issues/${number}/blocks`, {
|
|
index: blockedNumber,
|
|
owner: this.config.repoOwner,
|
|
repo: this.config.repoName,
|
|
});
|
|
}
|
|
|
|
// ====== 辅助方法 ======
|
|
|
|
/** 通过名称查找标签,返回 ID(不存在则返回 null) */
|
|
async findLabelByName(name) {
|
|
const labels = await this.listLabels();
|
|
return labels.find(l => l.name === name) || null;
|
|
}
|
|
|
|
/** 通过名称查找标签,不存在则创建,返回标签对象 */
|
|
async ensureLabel(name, color) {
|
|
const existing = await this.findLabelByName(name);
|
|
if (existing) return existing;
|
|
return this.createLabel({ name, color });
|
|
}
|
|
|
|
/** 通过标题查找 Milestone,返回 ID */
|
|
async findMilestoneByTitle(title) {
|
|
const milestones = await this.listMilestones();
|
|
return milestones.find(m => m.title === title) || null;
|
|
}
|
|
|
|
/** 通过标题查找 Issue(模糊匹配) */
|
|
async findIssueByTitle(title) {
|
|
const issues = await this.listIssues({ state: 'all' });
|
|
return issues.find(i => i.title.includes(title)) || null;
|
|
}
|
|
}
|
|
|
|
module.exports = { GiteaAPI };
|