841 lines
30 KiB
JavaScript
841 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Gitea CLI — 项目管理工具
|
||
*
|
||
* 用法: node gitea.js --role <角色> <command> [subcommand] [options]
|
||
* 所有配置(连接信息、Token、角色映射、标签定义)从 gitea-config.json 读取。
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { GiteaAPI } = require('./gitea-api');
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 加载配置
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
const CONFIG_PATH = path.join(__dirname, 'gitea-config.json');
|
||
let config;
|
||
try {
|
||
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||
} catch (e) {
|
||
console.error(`❌ 无法读取配置文件: ${CONFIG_PATH}`);
|
||
console.error(` ${e.message}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 命令行参数解析
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
function parseArgs(argv) {
|
||
const positional = [];
|
||
const options = {};
|
||
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const arg = argv[i];
|
||
if (arg === '--') { positional.push(...argv.slice(i + 1)); break; }
|
||
if (arg.startsWith('--')) {
|
||
const key = arg.slice(2);
|
||
const next = argv[i + 1];
|
||
if (next && !next.startsWith('--')) {
|
||
options[key] = next;
|
||
i++;
|
||
} else {
|
||
options[key] = true;
|
||
}
|
||
} else {
|
||
positional.push(arg);
|
||
}
|
||
}
|
||
return { command: positional[0] || '', sub: positional[1] || '', rest: positional.slice(2), options };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 输出格式化
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
function out(data) {
|
||
if (globalArgs.options.json) {
|
||
console.log(JSON.stringify(data, null, 2));
|
||
}
|
||
}
|
||
|
||
function outText(text) {
|
||
if (!globalArgs.options.json) console.log(text);
|
||
}
|
||
|
||
function ok(msg) { outText(`✅ ${msg}`); }
|
||
function fail(msg) { outText(`❌ ${msg}`); }
|
||
function info(msg) { outText(`ℹ️ ${msg}`); }
|
||
|
||
function truncate(s, max = 60) {
|
||
if (!s) return '';
|
||
const oneLine = s.replace(/\n/g, ' ').trim();
|
||
return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine;
|
||
}
|
||
|
||
function labelNames(issue) {
|
||
return (issue.labels || []).map(l => l.name);
|
||
}
|
||
|
||
/** 简易表格输出 */
|
||
function table(headers, rows) {
|
||
if (!rows.length) { outText(' (空)'); return; }
|
||
const widths = headers.map((h, i) =>
|
||
Math.max(h.length, ...rows.map(r => String(r[i] ?? '').length)))
|
||
;
|
||
const fmt = (cells) => cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' ');
|
||
outText(' ' + fmt(headers));
|
||
outText(' ' + fmt(headers.map(() => '─'.repeat(2))));
|
||
for (const r of rows) outText(' ' + fmt(r));
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 角色解析
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
/**
|
||
* 获取 Token(优先级:环境变量 > config.roles[role].token)
|
||
*/
|
||
function resolveToken(options) {
|
||
if (process.env.GITEA_TOKEN) return process.env.GITEA_TOKEN;
|
||
|
||
const role = options.role;
|
||
if (role) {
|
||
const validRoles = Object.keys(config.roles);
|
||
if (!validRoles.includes(role)) {
|
||
console.error(`❌ 无效角色: ${role},可选: ${validRoles.join(', ')}`);
|
||
process.exit(1);
|
||
}
|
||
const token = config.roles[role]?.token;
|
||
if (!token) {
|
||
console.error(`❌ 角色 "${role}" 未配置 token。请在 gitea-config.json 中设置。`);
|
||
process.exit(1);
|
||
}
|
||
return token;
|
||
}
|
||
|
||
console.error('❌ 缺少认证信息。请使用以下方式之一:');
|
||
console.error(' --role <角色名> 从 gitea-config.json 读取 Token');
|
||
console.error(' GITEA_TOKEN=xxx 通过环境变量传入');
|
||
process.exit(1);
|
||
}
|
||
|
||
/**
|
||
* 解析 assignee:支持角色名(自动映射)或直接用户名
|
||
*/
|
||
function resolveAssignee(value) {
|
||
if (!value) return null;
|
||
return config.roles[value]?.assignee || value;
|
||
}
|
||
|
||
/**
|
||
* 自动从 --role 解析 assignee(用于 issue create)
|
||
*/
|
||
function autoAssignee(role) {
|
||
if (!role) return [];
|
||
const assignee = config.roles[role]?.assignee;
|
||
return assignee ? [assignee] : [];
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 命令实现
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
const cmd = {};
|
||
|
||
// ──────── issue ────────
|
||
|
||
cmd.issue = {
|
||
async list(api, opts) {
|
||
const params = {};
|
||
if (opts.options.state) params.state = opts.options.state;
|
||
if (opts.options.milestone) {
|
||
const ms = isNaN(opts.options.milestone)
|
||
? await api.findMilestoneByTitle(opts.options.milestone)
|
||
: { id: Number(opts.options.milestone) };
|
||
if (ms) params.milestones = ms.id;
|
||
else { fail(`未找到里程碑: ${opts.options.milestone}`); return; }
|
||
}
|
||
if (opts.options.labels) params.labels = opts.options.labels;
|
||
if (opts.options.assigned) {
|
||
// 支持角色名或直接用户名
|
||
params.assigned_by = resolveAssignee(opts.options.assigned);
|
||
}
|
||
|
||
const issues = await api.listIssues(params);
|
||
out(issues);
|
||
if (!opts.options.json) {
|
||
outText(`\n📋 Issues (${issues.length || 0})\n`);
|
||
if (!issues || !issues.length) { outText(' (无)'); return; }
|
||
table(
|
||
['#', '标题', '状态', '里程碑', '标签'],
|
||
issues.map(i => [
|
||
i.number,
|
||
truncate(i.title, 40),
|
||
i.state,
|
||
i.milestone?.title || '—',
|
||
labelNames(i).join(', ') || '—',
|
||
])
|
||
);
|
||
}
|
||
},
|
||
|
||
async get(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue get <number>'); return; }
|
||
const issue = await api.getIssue(num);
|
||
out(issue);
|
||
if (!opts.options.json) {
|
||
outText(`\n📌 Issue #${issue.number}: ${issue.title}`);
|
||
outText(` 状态: ${issue.state}`);
|
||
outText(` 里程碑: ${issue.milestone?.title || '—'}`);
|
||
outText(` 指派: ${(issue.assignees || []).map(a => a.login).join(', ') || '—'}`);
|
||
outText(` 标签: ${labelNames(issue).join(', ') || '—'}`);
|
||
outText(` 创建: ${issue.created_at?.slice(0, 10)}`);
|
||
outText(` 更新: ${issue.updated_at?.slice(0, 10)}`);
|
||
if (issue.body) {
|
||
outText(`\n--- Body ---\n${issue.body.slice(0, 500)}${issue.body.length > 500 ? '…' : ''}`);
|
||
}
|
||
}
|
||
},
|
||
|
||
async create(api, opts) {
|
||
const title = opts.options.title;
|
||
const body = opts.options.body || '';
|
||
if (!title) { fail('用法: issue create --title "标题" [--body "内容"] [--milestone <id>] [--labels <id,...>]'); return; }
|
||
|
||
const data = { title, body };
|
||
if (opts.options.milestone) {
|
||
const ms = isNaN(opts.options.milestone)
|
||
? await api.findMilestoneByTitle(opts.options.milestone)
|
||
: { id: Number(opts.options.milestone) };
|
||
if (ms) data.milestone = ms.id;
|
||
}
|
||
if (opts.options.labels) {
|
||
data.labels = opts.options.labels.split(',').map(Number);
|
||
}
|
||
// assignee: 显式指定 > 自动从 --role 映射
|
||
if (opts.options.assignees) {
|
||
data.assignees = opts.options.assignees.split(',').map(a => resolveAssignee(a));
|
||
} else {
|
||
data.assignees = autoAssignee(opts.options.role);
|
||
}
|
||
|
||
const issue = await api.createIssue(data);
|
||
out(issue);
|
||
if (!opts.options.json) {
|
||
ok(`Issue #${issue.number} 已创建: ${issue.title}`);
|
||
outText(` ${issue.html_url}`);
|
||
}
|
||
},
|
||
|
||
async update(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue update <number> [--title "…"] [--body "…"] [--state open|closed]'); return; }
|
||
|
||
const data = {};
|
||
if (opts.options.title !== undefined) data.title = opts.options.title;
|
||
if (opts.options.body !== undefined) data.body = opts.options.body;
|
||
if (opts.options.state) data.state = opts.options.state;
|
||
if (opts.options.milestone) {
|
||
const ms = isNaN(opts.options.milestone)
|
||
? await api.findMilestoneByTitle(opts.options.milestone)
|
||
: { id: Number(opts.options.milestone) };
|
||
if (ms) data.milestone = ms.id;
|
||
}
|
||
if (opts.options.assignees) {
|
||
data.assignees = opts.options.assignees.split(',').map(a => resolveAssignee(a));
|
||
}
|
||
|
||
const issue = await api.updateIssue(num, data);
|
||
out(issue);
|
||
if (!opts.options.json) ok(`Issue #${num} 已更新`);
|
||
},
|
||
|
||
async close(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue close <number>'); return; }
|
||
const comment = opts.options.body || opts.options.comment;
|
||
await api.closeIssue(num, comment);
|
||
out({ closed: true, number: Number(num) });
|
||
if (!opts.options.json) ok(`Issue #${num} 已关闭`);
|
||
},
|
||
|
||
async reopen(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue reopen <number>'); return; }
|
||
await api.reopenIssue(num);
|
||
out({ reopened: true, number: Number(num) });
|
||
if (!opts.options.json) ok(`Issue #${num} 已重新打开`);
|
||
},
|
||
|
||
async comment(api, opts) {
|
||
const num = opts.rest[0];
|
||
const body = opts.options.body;
|
||
if (!num || !body) { fail('用法: issue comment <number> --body "内容"'); return; }
|
||
const c = await api.createComment(num, body);
|
||
out(c);
|
||
if (!opts.options.json) ok(`评论已添加到 Issue #${num}`);
|
||
},
|
||
|
||
async comments(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue comments <number>'); return; }
|
||
const comments = await api.listComments(num);
|
||
out(comments);
|
||
if (!opts.options.json) {
|
||
outText(`\n💬 Issue #${num} 的评论 (${comments.length})\n`);
|
||
if (!comments.length) { outText(' (无评论)'); return; }
|
||
for (const c of comments) {
|
||
outText(` @${c.user?.login || '?'} (${c.created_at?.slice(0, 10)}):`);
|
||
outText(` ${truncate(c.body, 80)}`);
|
||
}
|
||
}
|
||
},
|
||
|
||
async labels(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue labels <number> [--add <id,...>] [--set <id,...>]'); return; }
|
||
|
||
if (opts.options.add) {
|
||
const ids = opts.options.add.split(',').map(Number);
|
||
await api.addIssueLabels(num, ids);
|
||
if (!opts.options.json) ok(`标签已添加到 Issue #${num}: ${ids.join(', ')}`);
|
||
} else if (opts.options.set) {
|
||
const ids = opts.options.set.split(',').map(Number);
|
||
await api.replaceIssueLabels(num, ids);
|
||
if (!opts.options.json) ok(`Issue #${num} 的标签已替换为: ${ids.join(', ')}`);
|
||
} else {
|
||
const labels = await api.getIssueLabels(num);
|
||
out(labels);
|
||
if (!opts.options.json) {
|
||
outText(`\n🏷️ Issue #${num} 的标签 (${labels.length})`);
|
||
for (const l of labels) {
|
||
outText(` ${l.name} (${l.id}) #${l.color}`);
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
async deps(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: issue deps <number>'); return; }
|
||
const [blockedBy, blocking] = await Promise.all([
|
||
api.listDependencies(num).catch(() => []),
|
||
api.listBlocks(num).catch(() => []),
|
||
]);
|
||
out({ issue: Number(num), blockedBy, blocking });
|
||
if (!opts.options.json) {
|
||
outText(`\n🔗 Issue #${num} 的依赖关系`);
|
||
outText(` 被阻塞于 (${blockedBy.length}): ${blockedBy.map(i => '#' + i.number).join(', ') || '无'}`);
|
||
outText(` 阻塞了 (${blocking.length}): ${blocking.map(i => '#' + i.number).join(', ') || '无'}`);
|
||
}
|
||
},
|
||
|
||
async block(api, opts) {
|
||
const [num, blocked] = opts.rest;
|
||
if (!num || !blocked) { fail('用法: issue block <number> <blocked_number>'); return; }
|
||
await api.addBlock(Number(num), Number(blocked));
|
||
out({ blocker: Number(num), blocked: Number(blocked) });
|
||
if (!opts.options.json) ok(`Issue #${blocked} 现在依赖于 Issue #${num}`);
|
||
},
|
||
|
||
async unblock(api, opts) {
|
||
const [num, blocked] = opts.rest;
|
||
if (!num || !blocked) { fail('用法: issue unblock <number> <blocked_number>'); return; }
|
||
await api.removeBlock(Number(num), Number(blocked));
|
||
out({ blocker: Number(num), unblocked: Number(blocked) });
|
||
if (!opts.options.json) ok(`Issue #${blocked} 不再依赖于 Issue #${num}`);
|
||
},
|
||
};
|
||
|
||
// ──────── milestone ────────
|
||
|
||
cmd.milestone = {
|
||
async list(api, opts) {
|
||
const milestones = await api.listMilestones({ state: opts.options.state || 'all' });
|
||
out(milestones);
|
||
if (!opts.options.json) {
|
||
outText(`\n🏁 里程碑 (${milestones.length})\n`);
|
||
if (!milestones.length) { outText(' (无)'); return; }
|
||
table(
|
||
['ID', '标题', '状态', 'Open', 'Closed', '截止日期'],
|
||
milestones.map(m => [
|
||
m.id,
|
||
m.title,
|
||
m.state,
|
||
m.open_issues,
|
||
m.closed_issues,
|
||
m.due_on?.slice(0, 10) || '—',
|
||
])
|
||
);
|
||
}
|
||
},
|
||
|
||
async create(api, opts) {
|
||
const title = opts.options.title;
|
||
if (!title) { fail('用法: milestone create --title "标题" [--description "描述"] [--due YYYY-MM-DD]'); return; }
|
||
const data = { title, state: 'open' };
|
||
if (opts.options.description) data.description = opts.options.description;
|
||
if (opts.options.due) data.due_on = opts.options.due + 'T00:00:00Z';
|
||
const ms = await api.createMilestone(data);
|
||
out(ms);
|
||
if (!opts.options.json) ok(`里程碑已创建: ${ms.title} (ID: ${ms.id})`);
|
||
},
|
||
|
||
async update(api, opts) {
|
||
const id = opts.rest[0];
|
||
if (!id) { fail('用法: milestone update <id> [--title "…"] [--description "…"] [--state open|closed]'); return; }
|
||
const data = {};
|
||
if (opts.options.title) data.title = opts.options.title;
|
||
if (opts.options.description !== undefined) data.description = opts.options.description;
|
||
if (opts.options.state) data.state = opts.options.state;
|
||
if (opts.options.due) data.due_on = opts.options.due + 'T00:00:00Z';
|
||
const ms = await api.updateMilestone(id, data);
|
||
out(ms);
|
||
if (!opts.options.json) ok(`里程碑 ${id} 已更新`);
|
||
},
|
||
};
|
||
|
||
// ──────── label ────────
|
||
|
||
cmd.label = {
|
||
async list(api, opts) {
|
||
const labels = await api.listLabels();
|
||
out(labels);
|
||
if (!opts.options.json) {
|
||
outText(`\n🏷️ 标签 (${labels.length})\n`);
|
||
if (!labels.length) { outText(' (无)'); return; }
|
||
table(
|
||
['ID', '名称', '颜色'],
|
||
labels.map(l => [l.id, l.name, '#' + l.color])
|
||
);
|
||
}
|
||
},
|
||
|
||
async create(api, opts) {
|
||
const name = opts.options.name;
|
||
const color = (opts.options.color || '#ededed').replace('#', '');
|
||
if (!name) { fail('用法: label create --name "名称" [--color "#RRGGBB"]'); return; }
|
||
const label = await api.createLabel({ name, color });
|
||
out(label);
|
||
if (!opts.options.json) ok(`标签已创建: ${name} (ID: ${label.id})`);
|
||
},
|
||
|
||
async init(api, opts) {
|
||
outText('\n🏷️ 初始化标准标签体系…\n');
|
||
// 从 config.labels 合并所有标签定义
|
||
const allLabels = Object.values(config.labels).flat();
|
||
const existing = await api.listLabels();
|
||
const existingNames = new Set(existing.map(l => l.name));
|
||
let created = 0, skipped = 0;
|
||
|
||
for (const def of allLabels) {
|
||
if (existingNames.has(def.name)) {
|
||
info(`跳过 (已存在): ${def.name}`);
|
||
skipped++;
|
||
} else {
|
||
await api.createLabel({ name: def.name, color: def.color.replace('#', ''), description: def.description });
|
||
ok(`已创建: ${def.name}`);
|
||
created++;
|
||
}
|
||
}
|
||
outText(`\n完成: 新建 ${created} 个, 跳过 ${skipped} 个`);
|
||
},
|
||
};
|
||
|
||
// ──────── pipeline ────────
|
||
|
||
cmd.pipeline = {
|
||
async init(api, opts) {
|
||
const title = opts.options.title || 'Pipeline Tracker';
|
||
const milestone = opts.options.milestone;
|
||
|
||
const body = `# 流水线进度追踪
|
||
|
||
## 当前阶段
|
||
草图阶段
|
||
|
||
## 当前迭代
|
||
(非迭代阶段,留空)
|
||
|
||
## 阶段历史
|
||
| 阶段 | 里程碑 | 进入日期 | 完成日期 |
|
||
|------|--------|----------|----------|
|
||
| 草图阶段 | 草图阶段 | ${new Date().toISOString().slice(0, 10)} | — |`;
|
||
|
||
const data = { title, body };
|
||
if (milestone) {
|
||
const ms = isNaN(milestone)
|
||
? await api.findMilestoneByTitle(milestone)
|
||
: { id: Number(milestone) };
|
||
if (ms) data.milestone = ms.id;
|
||
}
|
||
|
||
const issue = await api.createIssue(data);
|
||
out(issue);
|
||
if (!opts.options.json) {
|
||
ok(`Pipeline Tracker 已创建: Issue #${issue.number}`);
|
||
outText(` ${issue.html_url}`);
|
||
}
|
||
},
|
||
|
||
async status(api, opts) {
|
||
let tracker = await api.findIssueByTitle('Pipeline Tracker');
|
||
if (!tracker) { fail('未找到 Pipeline Tracker Issue。请先运行: pipeline init'); return; }
|
||
tracker = await api.getIssue(tracker.number);
|
||
out({ number: tracker.number, title: tracker.title, body: tracker.body });
|
||
if (!opts.options.json) {
|
||
outText(`\n📊 Pipeline Tracker #${tracker.number}\n`);
|
||
outText(tracker.body);
|
||
}
|
||
},
|
||
|
||
async update(api, opts) {
|
||
const num = opts.rest[0];
|
||
const field = opts.options.field;
|
||
const value = opts.options.value;
|
||
if (!num || !field || !value) {
|
||
fail('用法: pipeline update <issue-number> --field <阶段|当前迭代> --value <值>');
|
||
return;
|
||
}
|
||
|
||
const issue = await api.getIssue(num);
|
||
let body = issue.body || '';
|
||
|
||
if (field === '阶段') {
|
||
body = body.replace(/## 当前阶段\n.*/, `## 当前阶段\n${value}`);
|
||
} else if (field === '当前迭代') {
|
||
body = body.replace(/## 当前迭代\n.*/, `## 当前迭代\n${value}`);
|
||
} else {
|
||
fail(`无效字段: ${field},可选: 阶段, 当前迭代`);
|
||
return;
|
||
}
|
||
|
||
await api.updateIssue(num, { body });
|
||
out({ number: Number(num), field, value, updated: true });
|
||
if (!opts.options.json) ok(`Pipeline Tracker 字段 "${field}" 已更新为: ${value}`);
|
||
},
|
||
};
|
||
|
||
// ──────── pr ────────
|
||
|
||
cmd.pr = {
|
||
async list(api, opts) {
|
||
const params = {};
|
||
if (opts.options.state) params.state = opts.options.state;
|
||
if (opts.options.base) params.base = opts.options.base;
|
||
if (opts.options.head) params.head = opts.options.head;
|
||
|
||
const prs = await api.listPullRequests(params);
|
||
out(prs);
|
||
if (!opts.options.json) {
|
||
outText(`\n🔀 Pull Requests (${prs.length || 0})\n`);
|
||
if (!prs || !prs.length) { outText(' (无)'); return; }
|
||
table(
|
||
['#', '标题', '状态', 'Base', 'Head', '合并'],
|
||
prs.map(p => [
|
||
p.number,
|
||
truncate(p.title, 35),
|
||
p.state,
|
||
p.base?.ref || '—',
|
||
p.head?.ref || '—',
|
||
p.merged ? '✅' : '—',
|
||
])
|
||
);
|
||
}
|
||
},
|
||
|
||
async get(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: pr get <number>'); return; }
|
||
const pr = await api.getPullRequest(num);
|
||
out(pr);
|
||
if (!opts.options.json) {
|
||
outText(`\n🔀 PR #${pr.number}: ${pr.title}`);
|
||
outText(` 状态: ${pr.state}`);
|
||
outText(` ${pr.base?.ref} ← ${pr.head?.ref}`);
|
||
outText(` 合并: ${pr.merged ? '✅ 已合并' : '❌ 未合并'}`);
|
||
if (pr.body) {
|
||
outText(`\n--- Body ---\n${pr.body.slice(0, 500)}${pr.body.length > 500 ? '…' : ''}`);
|
||
}
|
||
}
|
||
},
|
||
|
||
async create(api, opts) {
|
||
const base = opts.options.base || 'main';
|
||
const head = opts.options.head;
|
||
const title = opts.options.title;
|
||
const body = opts.options.body || '';
|
||
|
||
if (!head || !title) {
|
||
fail('用法: pr create --head <分支> --title "标题" [--base main] [--body "描述"]');
|
||
return;
|
||
}
|
||
|
||
const pr = await api.createPullRequest({ base, head, title, body });
|
||
out(pr);
|
||
if (!opts.options.json) {
|
||
ok(`PR #${pr.number} 已创建: ${pr.title}`);
|
||
outText(` ${pr.html_url}`);
|
||
}
|
||
},
|
||
|
||
async merge(api, opts) {
|
||
const num = opts.rest[0];
|
||
if (!num) { fail('用法: pr merge <number> [--style merge|squash|rebase]'); return; }
|
||
const style = opts.options.style || 'merge';
|
||
|
||
const pr = await api.getPullRequest(num);
|
||
if (pr.merged) { fail(`PR #${num} 已合并,无需重复操作`); return; }
|
||
if (pr.mergeable === false) { fail(`PR #${num} 存在冲突,无法自动合并。请先解决冲突。`); return; }
|
||
|
||
const result = await api.mergePullRequest(num, { style });
|
||
out(result);
|
||
if (!opts.options.json) ok(`PR #${num} 已合并 (${style})`);
|
||
},
|
||
};
|
||
|
||
// ──────── test ────────
|
||
|
||
cmd.test = {
|
||
async run(api, opts) {
|
||
outText('\n🧪 Gitea API 接口验证\n');
|
||
const results = [];
|
||
let passed = 0, failed = 0;
|
||
|
||
async function test(name, fn) {
|
||
try {
|
||
const result = await fn();
|
||
ok(name);
|
||
results.push({ name, status: 'pass', detail: result });
|
||
passed++;
|
||
} catch (e) {
|
||
fail(`${name} — ${e.message}`);
|
||
results.push({ name, status: 'fail', error: e.message });
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
// P0: 阻塞性验证
|
||
outText('── P0: 阻塞性验证 ──\n');
|
||
|
||
await test('GET /milestones', () => api.listMilestones());
|
||
await test('GET /issues', () => api.listIssues({ limit: 5 }));
|
||
await test('GET /labels', () => api.listLabels());
|
||
|
||
// 查找第一个存在的 Issue 用于后续测试
|
||
const issues = await api.listIssues({ limit: 5, state: 'all' });
|
||
const testIssue = issues.find(i => i.state === 'open') || issues[0];
|
||
if (testIssue) {
|
||
await test(`GET /issues/${testIssue.number}`, () => api.getIssue(testIssue.number));
|
||
await test(`GET /issues/${testIssue.number}/comments`, () => api.listComments(testIssue.number));
|
||
await test(`GET /issues/${testIssue.number}/dependencies`, () => api.listDependencies(testIssue.number));
|
||
await test(`GET /issues/${testIssue.number}/blocks`, () => api.listBlocks(testIssue.number));
|
||
}
|
||
|
||
await test('POST 创建含中文 Issue', async () => {
|
||
const i = await api.createIssue({
|
||
title: '测试 Issue — UTF-8 验证 🎮',
|
||
body: '这是中文内容测试。\n\n- 列表项\n- 特殊字符:✅❌🔴⬜\n\n创建时间: ' + new Date().toISOString(),
|
||
});
|
||
await api.closeIssue(i.number);
|
||
return `#${i.number}: ${i.title}`;
|
||
});
|
||
|
||
if (testIssue) {
|
||
await test('POST 创建含中文评论', async () => {
|
||
const c = await api.createComment(testIssue.number, '🧪 API 评论测试 — 中文编码验证 ' + new Date().toISOString().slice(0, 19));
|
||
return `comment_id: ${c.id}`;
|
||
});
|
||
|
||
await test('PATCH 更新 Issue', async () => {
|
||
const orig = await api.getIssue(testIssue.number);
|
||
const newTitle = orig.title + ' [测试更新]';
|
||
await api.updateIssue(testIssue.number, { title: newTitle });
|
||
await api.updateIssue(testIssue.number, { title: orig.title });
|
||
return `Issue #${testIssue.number} 标题更新并还原`;
|
||
});
|
||
}
|
||
|
||
await test('PATCH 关闭/重开 Issue', async () => {
|
||
const tmp = await api.createIssue({ title: '临时测试 — 开关', body: '将被关闭再打开' });
|
||
await api.closeIssue(tmp.number);
|
||
await api.reopenIssue(tmp.number);
|
||
await api.closeIssue(tmp.number);
|
||
return `#${tmp.number} 关闭→重开→关闭`;
|
||
});
|
||
|
||
await test('POST 创建 Label', async () => {
|
||
const l = await api.createLabel({ name: '_test-label', color: 'ededed' });
|
||
try { await api.deleteLabel(l.id); } catch { /* ignore */ }
|
||
return `label: ${l.name} (ID: ${l.id}) 已创建并清理`;
|
||
});
|
||
|
||
if (testIssue) {
|
||
await test('POST Issue 添加标签', async () => {
|
||
const labels = await api.listLabels();
|
||
if (!labels.length) return '无可用标签,跳过';
|
||
await api.addIssueLabels(testIssue.number, [labels[0].id]);
|
||
return `添加标签 ${labels[0].name} 到 Issue #${testIssue.number}`;
|
||
});
|
||
}
|
||
|
||
// P1: 核心功能
|
||
outText('\n── P1: 核心功能验证 ──\n');
|
||
|
||
if (testIssue && issues.length >= 2) {
|
||
const other = issues.find(i => i.number !== testIssue.number);
|
||
if (other) {
|
||
await test('POST/DELETE Issue 依赖', async () => {
|
||
await api.addBlock(testIssue.number, other.number);
|
||
await api.removeBlock(testIssue.number, other.number);
|
||
return `依赖关系 ${testIssue.number}→${other.number} 添加→移除`;
|
||
});
|
||
}
|
||
}
|
||
|
||
await test('POST/PATCH Milestone', async () => {
|
||
const ms = await api.createMilestone({ title: '_test-milestone', state: 'open' });
|
||
await api.updateMilestone(ms.id, { description: '测试用' });
|
||
await api.updateMilestone(ms.id, { state: 'closed' });
|
||
return `milestone ${ms.id} 创建→更新→关闭`;
|
||
});
|
||
|
||
// 汇总
|
||
outText(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
||
outText(`总计: ${passed + failed} 项, ✅ ${passed} 通过, ❌ ${failed} 失败`);
|
||
out(results);
|
||
|
||
if (failed > 0) process.exit(1);
|
||
},
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 帮助文本
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
function showHelp() {
|
||
const roles = Object.keys(config.roles).join('|');
|
||
console.log(`
|
||
Gitea CLI — 项目管理工具
|
||
|
||
用法: node gitea.js --role <${roles}> <command> [subcommand] [options]
|
||
|
||
全局选项:
|
||
--help 显示帮助信息
|
||
--json 以 JSON 格式输出
|
||
--role <角色> 使用对应角色的身份(自动解析 Token 和 Assignee)
|
||
|
||
命令列表:
|
||
|
||
issue list 列出 Issues
|
||
[--state open|closed|all] [--milestone <id|标题>]
|
||
[--labels <id,...>] [--assigned <角色名|用户名>]
|
||
issue get <#> 查看 Issue 详情
|
||
issue create 创建 Issue(assignee 自动从 --role 映射)
|
||
--title "标题" [--body "内容"] [--milestone <id>]
|
||
[--labels <id,...>] [--assignees <角色名,...>]
|
||
issue update <#> 更新 Issue [--title "…"] [--body "…"] [--state open|closed]
|
||
issue close <#> 关闭 Issue [--body "完成评论"]
|
||
issue reopen <#> 重新打开 Issue
|
||
issue comment <#> 添加评论 --body "内容"
|
||
issue comments <#> 查看评论列表
|
||
issue labels <#> 查看/管理标签 [--add <id,...>] [--set <id,...>]
|
||
issue deps <#> 查看依赖关系
|
||
issue block <#> <#> 设置依赖关系
|
||
issue unblock <#> <#> 移除依赖关系
|
||
|
||
milestone list 列出里程碑 [--state open|closed|all]
|
||
milestone create 创建里程碑 --title "标题" [--description "…"] [--due YYYY-MM-DD]
|
||
milestone update <id> 更新里程碑 [--title "…"] [--state open|closed]
|
||
|
||
label list 列出标签
|
||
label create 创建标签 --name "名称" [--color "#RRGGBB"]
|
||
label init 初始化标准标签体系(从 gitea-config.json 读取定义)
|
||
|
||
pipeline init 创建 Pipeline Tracker Meta Issue
|
||
pipeline status 查看当前流水线进度
|
||
pipeline update <#> 更新 Pipeline 字段 --field <阶段|当前迭代> --value <值>
|
||
|
||
pr list 列出 Pull Requests [--state open|closed|all] [--base <分支>] [--head <分支>]
|
||
pr get <#> 查看 PR 详情
|
||
pr create 创建 PR --head <分支> --title "标题" [--base main] [--body "描述"]
|
||
pr merge <#> 合并 PR [--style merge|squash|rebase]
|
||
|
||
test 验证所有 API 接口
|
||
|
||
角色自动映射:
|
||
--role <角色名> 自动解析为 Token + Assignee
|
||
--assigned <角色名> 自动解析为 Gitea 用户名
|
||
--assignees <角色名,...> 同上
|
||
|
||
配置文件: gitea-config.json(与 gitea.js 同目录)
|
||
`);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════
|
||
// 路由与入口
|
||
// ═══════════════════════════════════════════════════
|
||
|
||
let globalArgs;
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
globalArgs = args;
|
||
|
||
if (args.options.help || (!args.command && !args.sub)) {
|
||
showHelp();
|
||
return;
|
||
}
|
||
|
||
// Token 解析
|
||
const token = resolveToken(args.options);
|
||
|
||
// 构建 API 实例(从 config 读取连接信息)
|
||
const api = new GiteaAPI(token, {
|
||
host: config.host,
|
||
port: config.port,
|
||
repoOwner: config.repoOwner,
|
||
repoName: config.repoName,
|
||
});
|
||
|
||
// 路由
|
||
const commandGroup = cmd[args.command];
|
||
if (!commandGroup) {
|
||
console.error(`❌ 未知命令: ${args.command}`);
|
||
console.error(' 运行 --help 查看可用命令');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (args.command === 'test') {
|
||
await cmd.test.run(api, args);
|
||
return;
|
||
}
|
||
|
||
const handler = commandGroup[args.sub];
|
||
if (!handler) {
|
||
console.error(`❌ 未知子命令: ${args.command} ${args.sub}`);
|
||
console.error(` 运行 --help 查看 ${args.command} 的可用子命令`);
|
||
process.exit(1);
|
||
}
|
||
|
||
try {
|
||
await handler(api, args);
|
||
} catch (e) {
|
||
if (args.options.json) {
|
||
console.error(JSON.stringify({ error: e.message, statusCode: e.statusCode }));
|
||
} else {
|
||
console.error(`\n❌ 错误: ${e.message}`);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
main().catch(e => {
|
||
console.error('致命错误:', e);
|
||
process.exit(1);
|
||
});
|