/** * 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} 解析后的 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 };