feat(tools): 新增 Gitea CLI 工具并迁移进度管理到 Issue/Milestone
- 新增 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 中文编码)
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user