eee3a4925c
- 新增 scripts/gitea.js CLI(20+ 子命令,--role Token 管理) - 新增 scripts/gitea-api.js API 客户端模块 - 新增 scripts/.gitea-tokens.json(gitignored,按角色存 Token) - 更新 .gitignore 排除 Token 文件 - 更新 gitea-api-guide.md 新增 CLI 用法和标签体系文档 - Gitea 上初始化 23 个 Labels + 8 个 Issues + 依赖链 - 16/16 API 接口验证全部通过(含 UTF-8 中文编码)
255 lines
7.1 KiB
JavaScript
255 lines
7.1 KiB
JavaScript
/**
|
||
* Gitea API Client — ChildHood 项目进度管理
|
||
*
|
||
* 封装所有 Gitea REST API 调用,处理 UTF-8 编码和错误重试。
|
||
* 供 gitea.js CLI 使用。
|
||
*/
|
||
|
||
const http = require('http');
|
||
|
||
const DEFAULT_CONFIG = {
|
||
host: 'micecola.top',
|
||
port: 8418,
|
||
apiBase: '/api/v1',
|
||
repoPath: '/repos/starmen1265/ChildHood',
|
||
timeout: 15000,
|
||
};
|
||
|
||
class GiteaAPI {
|
||
/**
|
||
* @param {string} token - Gitea API Token
|
||
* @param {object} [config] - 覆盖默认配置
|
||
*/
|
||
constructor(token, config = {}) {
|
||
if (!token) throw new Error('GITEA_TOKEN 未设置。请设置环境变量 GITEA_TOKEN。');
|
||
this.token = token;
|
||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||
}
|
||
|
||
// ====== 底层 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) {
|
||
// Gitea 要求 body 为 IssueMeta 格式(含 owner + repo)
|
||
return this.request('POST', `/issues/${number}/blocks`, {
|
||
index: blockedNumber,
|
||
owner: this.config.repoPath.split('/')[2], // /repos/{owner}/{repo}
|
||
repo: this.config.repoPath.split('/')[3],
|
||
});
|
||
}
|
||
|
||
/** 移除依赖 */
|
||
removeBlock(number, blockedNumber) {
|
||
return this.request('DELETE', `/issues/${number}/blocks`, {
|
||
index: blockedNumber,
|
||
owner: this.config.repoPath.split('/')[2],
|
||
repo: this.config.repoPath.split('/')[3],
|
||
});
|
||
}
|
||
|
||
// ====== 辅助方法 ======
|
||
|
||
/** 通过名称查找标签,返回 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, DEFAULT_CONFIG };
|