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:
DESKTOP-2K5TJI4\admin
2026-06-05 19:07:37 +08:00
parent 0dfc90cfdf
commit eee3a4925c
6 changed files with 1370 additions and 3 deletions
@@ -1,7 +1,8 @@
# PM Agent Memory Index
- [流水线阶段 2026-06-05 P002](pipeline-status-20260605-p002.md) — P-002 质量门不通过,三个系统均需修正执行案
- [流水线阶段 2026-06-05](pipeline-status-20260605.md) — P-001 完成,v0.1 处于③执行案待确认(历史状态)
- [流水线阶段判断 2026-06-03](pipeline-status-20260603.md) — v0.1 处于阶段④执行案完善中,质量门未通过(历史状态)
- [进度管理迁移 Gitea](gitea-migration.md) — 2026-06-05 进度管理从本地文件迁移到 Gitea Issue + Milestone,废弃 current.md 和 reports/
- [流水线阶段 2026-06-05 P002](pipeline-status-20260605-p002.md) — P-002 质量门不通过,三个系统均需修正执行案(当前阻塞点)
- ~~[流水线阶段 2026-06-05](pipeline-status-20260605.md)~~ — 已过时,进度已迁移到 Gitea
- ~~[流水线阶段判断 2026-06-03](pipeline-status-20260603.md)~~ — 已过时,进度已迁移到 Gitea
- [Roadmap 对齐问题](roadmap-alignment.md) — roadmap.md 缺少 v0.5-v0.7,需与 game-vision.md 对齐
- [设计文档质量审查](design-doc-review.md) — 交互系统技术越界、收集系统缺v0.1执行细节、边界情况不足
@@ -0,0 +1,29 @@
---
name: gitea-migration
description: 进度管理已迁移到 Gitea Issue + Milestone,废弃 current.md 和 reports/
metadata:
type: project
---
进度管理迁移到 Gitea(2026-06-05)
**事实**: 进度管理唯一真实来源从本地 Markdown 文件迁移到 Gitea Issue + Milestone。
**废弃的文件**:
- `docs/plans/current.md` — 不再维护
- `docs/plans/reports/*.md` — 不再维护
- `docs/plans/archive/` — 不再新增(closed Issues = 归档)
**保留的本地文件**:
- `docs/plans/reviews/` — 审查记录(结构化文档)
- `docs/plans/prototype-roadmap.md` — 阶段路线图
- `docs/plans/roadmap.md` — 全局路线图
- `docs/plans/work-log.md` — 工作日志
**核心工具**: `scripts/gitea.js` CLI(Node.js),通过 `GITEA_TOKEN` 环境变量认证
**映射**: 任务→Issue, 版本→Milestone, 状态→Label, 汇报→Issue 评论, 归档→close Issue, 流水线→Pipeline Tracker Meta Issue
**Why**: 用户已在 Gitea 搭建 Issue/Milestone/看板基础设施,希望获得可视化看板和 Web 面板查看体验。
**How to apply**: 所有进度追踪操作必须通过 `scripts/gitea.js` CLI。不读 current.md,不写 reports/。
+5
View File
@@ -97,6 +97,11 @@ Desktop.ini
.claude/worktrees/
.worktrees/
# ==============================
# Gitea API Tokens(每台机器独立配置)
# ==============================
scripts/.gitea-tokens.json
# ==============================
# 其他
# ==============================
+291
View File
@@ -0,0 +1,291 @@
# Gitea API 访问指南
> 最后更新:2026-06-05
> 适用项目:ChildHood (starmen1265/ChildHood)
---
## 基本信息
| 项目 | 值 |
|------|-----|
| Gitea 版本 | 1.23.4 |
| Web 面板 | `http://micecola.top:8418` |
| SSH 端口 | 2223 |
| SSH 用户 | `sc-gitea` |
| API 基地址 | `http://micecola.top:8418/api/v1` |
| 仓库 API 路径 | `/repos/starmen1265/ChildHood` |
## Agent 账号
| 账号 | 用途 | 认证方式 |
|------|------|----------|
| `starmen1265` | 主开发者(用户) | 密码 + SSH |
| `ai-pm` | PM Agent | SSH Key + API Token |
| `ai-designer` | 策划 Agent | 待配置 |
| `ai-artist` | 美术 Agent | 待配置 |
---
## SSH 配置
PM Agent 的 SSH 密钥已配置在 `~/.ssh/id_ed25519_childhood_pm`。
现有 SSH Config(`~/.ssh/config`):
```
Host gitea micecola.top
HostName micecola.top
Port 2223
User sc-gitea
IdentityFile ~/.ssh/id_ed25519_gitea
IdentitiesOnly yes
```
> **注意**:当前 SSH Config 指向 `sc-gitea` 用户的密钥。如需用 `ai-pm` 身份操作,Git 命令需指定密钥:
> ```
> GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519_childhood_pm -p 2223" git push
> ```
---
## API 访问方式
### ⚠️ 重要:不要用 curl 发送中文
Windows 环境下 curl 发送中文 JSON 会出现编码错误(GBK/UTF-8 冲突),导致内容乱码。
**正确做法:使用 Python urllib**
```python
import urllib.request
import json
API_BASE = "http://micecola.top:8418/api/v1"
REPO_PATH = "/repos/starmen1265/ChildHood"
TOKEN = "<your-api-token>"
def gitea_api(method, path, data=None):
"""发送 Gitea API 请求
Args:
method: GET / POST / PATCH / DELETE
path: API 路径(如 /issues/1)
data: 请求体(dict),None 则无 body
Returns:
API 响应(dict)
"""
url = f"{API_BASE}{REPO_PATH}{path}"
body = json.dumps(data).encode('utf-8') if data else None
req = urllib.request.Request(url, data=body, headers={
'Authorization': f'token {TOKEN}',
'Content-Type': 'application/json; charset=utf-8'
}, method=method)
resp = urllib.request.urlopen(req, timeout=10)
return json.loads(resp.read().decode('utf-8'))
```
### API Token
各 Agent 账号的 Token 存储位置:
| Agent | Token 获取位置 |
|-------|---------------|
| `ai-pm` | Gitea → ai-pm 设置 → 应用 → Access Token |
> **安全提醒**:Token 不应硬编码在代码中。当前会话中由用户提供,每次新会话需重新获取或从安全存储读取。
---
## 常用 API 操作
### Issues
```python
# 列出所有 Issue(含已关闭)
issues = gitea_api('GET', '/issues?state=all')
# 列出某个里程碑下的 Issue
issues = gitea_api('GET', '/issues?milestones=2')
# 创建 Issue(关联里程碑)
gitea_api('POST', '/issues', {
'title': '任务标题',
'body': '任务描述(支持 Markdown)',
'milestone': 2, # 里程碑 ID
'assignees': ['ai-pm'] # 指派人(可选)
})
# 修改 Issue
gitea_api('PATCH', '/issues/1', {
'title': '新标题',
'body': '新内容',
'state': 'closed' # 关闭 Issue
})
# Issue 添加评论
gitea_api('POST', '/issues/1/comments', {
'body': '评论内容'
})
```
### Milestones(里程碑)
```python
# 列出所有里程碑
milestones = gitea_api('GET', '/milestones')
# 创建里程碑
gitea_api('POST', '/milestones', {
'title': 'v0.2-小游戏',
'description': '验证小游戏核心循环',
'state': 'open'
})
# 修改里程碑
gitea_api('PATCH', '/milestones/2', {
'title': '新标题',
'state': 'closed'
})
```
### Labels(标签)
```python
# 列出标签
labels = gitea_api('GET', '/labels')
# 创建标签
gitea_api('POST', '/labels', {
'name': '执行案',
'color': '#0052cc' # 十六进制颜色(不带 # 也行)
})
# 给 Issue 添加标签
gitea_api('POST', '/issues/1/labels', {
'labels': [1, 2] # 标签 ID 列表
})
```
---
## 已知限制
### Projects(项目看板)无 REST API
Gitea 1.23.4 **不支持** 项目看板的 REST API,端点返回 404。
- 功能请求追踪:[go-gitea/gitea#36824](https://github.com/go-gitea/gitea/issues/36824)
- 升级 Gitea 版本**不会**解决此问题(截至 2026-06,该功能尚未实现)
- 看板列的操作(如将 Issue 拖入 Backlog/To Do/In Progress/Done 列)只能通过 Web 页面手动完成
**替代方案**:使用 Issue 的 **状态**(open/closed)+ **标签** 追踪进度,看板仅作为可视化展示。
### 当前看板
- 看板地址:`http://micecola.top:8418/starmen1265/ChildHood/projects/2`
- 看板名称:**原型制作**
- 列结构:`Backlog → To Do → In Progress → Done`
---
## CLI 工具(推荐)
> 自 2026-06-05 起,所有 Agent 统一使用 CLI 工具操作 Gitea,不再直接使用 curl。
### 工具位置
`scripts/gitea.js` — Node.js CLI,封装所有 API 操作,自动处理 UTF-8 编码。
### Token 管理
Token 存储在 `scripts/.gitea-tokens.json`(已加入 `.gitignore`),每台机器配置一次。
```json
{
"pm": "<ai-pm 的 API Token>",
"designer": "<ai-designer 的 API Token>",
"dev": "<ai-dev 的 API Token>"
}
```
**Token 优先级**: `GITEA_TOKEN` 环境变量 > `--role <role>` 文件读取 > 报错
两台机器使用同一套 Token,只需将此文件拷贝到对应机器的 `scripts/` 目录下。
### 用法
```bash
# Agent 调用(通过 --role 自动选 Token,无需手动传 Token)
node scripts/gitea.js --role pm issue list --milestone v0.1-基础交互
node scripts/gitea.js --role designer issue comment 2 --body "汇报内容"
node scripts/gitea.js --role dev pipeline status
# 查看所有命令
node scripts/gitea.js --help
# 常用操作(以 --role pm 为例)
node scripts/gitea.js --role pm issue list --milestone v0.1-基础交互
node scripts/gitea.js --role pm issue get <number>
node scripts/gitea.js --role pm issue create --title "P-XXX 标题" --body "..." --milestone 2
node scripts/gitea.js --role pm issue close <number>
node scripts/gitea.js --role pm issue comment <number> --body "汇报内容"
node scripts/gitea.js --role pm issue labels <number> --set <id,...>
node scripts/gitea.js --role pm issue block <blocker> <blocked>
node scripts/gitea.js --role pm milestone list
node scripts/gitea.js --role pm label list
node scripts/gitea.js label init # 初始化标准标签体系
node scripts/gitea.js pipeline status
node scripts/gitea.js pipeline update <tracker-number> --step <1-7> --status "描述"
node scripts/gitea.js test # 验证所有 API 接口
# --json 标志输出纯 JSON(供脚本解析)
node scripts/gitea.js issue list --json
```
### 标准标签体系(23 个)
| 类别 | 标签 | 用途 |
|------|------|------|
| status | `status:backlog` `status:ready` `status:in-progress` `status:blocked` `status:in-review` `status:done` | 任务生命周期 |
| pipeline | `pipeline:3-execution` `pipeline:4-tech-design` `pipeline:5-art` `pipeline:6-impl` `pipeline:7-retro` | 流水线步骤 |
| role | `role:design` `role:dev` `role:art` `role:pm` | 角色归属 |
| gate | `gate:execution-pass/fail` `gate:tech-pass/fail` `gate:art-pass/fail` `gate:impl-pass/fail` | 质量门判定 |
### API 客户端模块
`scripts/gitea-api.js` — Node.js 模块,提供 `GiteaAPI` 类。可供自定义脚本引用:
```javascript
const { GiteaAPI } = require('./gitea-api');
const api = new GiteaAPI('your-token');
const issues = await api.listIssues({ state: 'open' });
```
---
## 当前里程碑与 Issue 状态
> 最后更新:2026-06-05
### 里程碑
| ID | 标题 | 状态 |
|----|------|------|
| 2 | v0.1-基础交互 | open |
### Issues(v0.1-基础交互)
| # | 标题 | 标签 | 状态 | 依赖 |
|---|------|------|------|------|
| 9 | P-001 原型阶段启动准备 | role:pm, status:done | ✅ 已完成 | — |
| 2 | P-002a 交互系统执行案 | status:blocked, pipeline:3, role:design, gate:execution-fail | 🔴 质量门未通过 | — |
| 1 | P-002b 收集系统执行案 | status:blocked, pipeline:3, role:design, gate:execution-fail | 🔴 质量门未通过 | — |
| 3 | P-002c 角色控制执行案 | status:blocked, pipeline:3, role:design, gate:execution-fail | 🔴 质量门未通过 | — |
| 10 | P-003 v0.1 ④程序设计 | status:backlog, pipeline:4, role:dev | ⬜ Backlog | #1, #2, #3 |
| 11 | P-004 v0.1 ⑤美术 + ⑥程序实现 | status:backlog, role:dev, role:art | ⬜ Backlog | #10 |
| 12 | P-005 v0.1 ⑦版本复盘 | status:backlog, pipeline:7, role:pm | ⬜ Backlog | #11 |
| 8 | Pipeline Tracker — v0.1 | role:pm, status:in-progress | 🔄 流水线追踪 | — |
+254
View File
@@ -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 };
+787
View File
@@ -0,0 +1,787 @@
#!/usr/bin/env node
/**
* ChildHood Gitea CLI — 项目进度管理工具
*
* 用法: node scripts/gitea.js <command> [subcommand] [options]
* 环境变量: GITEA_TOKEN(必须)
*/
const { GiteaAPI } = require('./gitea-api');
// ═══════════════════════════════════════════════════
// 标准标签定义
// ═══════════════════════════════════════════════════
const STANDARD_LABELS = [
// Status (6)
{ name: 'status:backlog', color: '#666666', description: '已定义但未排入当前工作' },
{ name: 'status:ready', color: '#007fff', description: '依赖已满足,可以开始' },
{ name: 'status:in-progress', color: '#e8b50a', description: '正在进行中' },
{ name: 'status:blocked', color: '#ee0701', description: '被阻塞(质量门未通过/依赖未完成)' },
{ name: 'status:in-review', color: '#8957e5', description: '等待质量门审查' },
{ name: 'status:done', color: '#0e8a16', description: '已完成' },
// Pipeline (5)
{ name: 'pipeline:3-execution', color: '#c5def5', description: '③ 执行案' },
{ name: 'pipeline:4-tech-design', color: '#bfdadc', description: '④ 程序设计' },
{ name: 'pipeline:5-art', color: '#d4c5f9', description: '⑤ 美术产出' },
{ name: 'pipeline:6-impl', color: '#fce6c0', description: '⑥ 程序实现' },
{ name: 'pipeline:7-retro', color: '#f0e5b0', description: '⑦ 版本复盘' },
// Role (4)
{ name: 'role:design', color: '#bfd4f2', description: '策划任务' },
{ name: 'role:dev', color: '#c1f0c1', description: '程序任务' },
{ name: 'role:art', color: '#ffccdd', description: '美术任务' },
{ name: 'role:pm', color: '#e0e0e0', description: 'PM 任务' },
// Gate (8)
{ name: 'gate:execution-pass', color: '#28a745', description: '执行案质量门 ✅' },
{ name: 'gate:execution-fail', color: '#d73a49', description: '执行案质量门 ❌' },
{ name: 'gate:tech-pass', color: '#28a745', description: '程序设计质量门 ✅' },
{ name: 'gate:tech-fail', color: '#d73a49', description: '程序设计质量门 ❌' },
{ name: 'gate:art-pass', color: '#28a745', description: '美术资产验收门 ✅' },
{ name: 'gate:art-fail', color: '#d73a49', description: '美术资产验收门 ❌' },
{ name: 'gate:impl-pass', color: '#28a745', description: '程序实现验收门 ✅' },
{ name: 'gate:impl-fail', color: '#d73a49', description: '程序实现验收门 ❌' },
];
// ═══════════════════════════════════════════════════
// 命令行参数解析
// ═══════════════════════════════════════════════════
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 outJson(data) {
console.log(JSON.stringify(data, null, 2));
}
/** 简易表格输出 */
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));
}
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;
}
/** 从 Issue 提取 label 名称列表 */
function labelNames(issue) {
return (issue.labels || []).map(l => l.name);
}
// ═══════════════════════════════════════════════════
// 命令实现
// ═══════════════════════════════════════════════════
const cmd = {};
// ──────── issue ────────
cmd.issue = {
async list(api, opts) {
const params = {};
if (opts.options.state) params.state = opts.options.state;
if (opts.options.milestone) {
// 支持数字 ID 或标题
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;
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);
}
if (opts.options.assignees) {
data.assignees = opts.options.assignees.split(',');
}
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(',');
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');
const existing = await api.listLabels();
const existingNames = new Set(existing.map(l => l.name));
let created = 0, skipped = 0;
for (const def of STANDARD_LABELS) {
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 — v0.1';
const milestone = opts.options.milestone;
const body = `# 流水线进度追踪
## v0.1 流水线
| 步骤 | 状态 | 说明 |
|------|------|------|
| ① 设计案 | ✅ 已完成 | \`docs/design/prototype/\` 下 10 份设计文档 |
| ② 阶段路线图 | ✅ 已完成 | \`docs/plans/prototype-roadmap.md\` |
| ③ 执行案 | 🔴 质量门未通过 | 需创建独立执行案并修正 |
| ④ 程序设计 | ⬜ 未开始 | 依赖 ③ |
| ⑤ 美术产出 | ⬜ 未开始 | 白盒场景 + 占位资产 |
| ⑥ 程序实现 | ⬜ 未开始 | 原型允许与 ⑤ 交替 |
| ⑦ 版本复盘 | ⬜ 未开始 | — |
## 当前阻塞
③ 执行案质量门未通过,需修正后重新提交
## 相关任务
> PM 在此维护 Issue 索引
`;
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) {
// 查找 Pipeline Tracker Issue
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 step = opts.options.step;
const status = opts.options.status;
if (!num || !step || !status) {
fail('用法: pipeline update <issue-number> --step <1-7> --status <状态描述>');
return;
}
const issue = await api.getIssue(num);
let body = issue.body || '';
const stepLabels = ['', '① 设计案', '② 阶段路线图', '③ 执行案', '④ 程序设计', '⑤ 美术产出', '⑥ 程序实现', '⑦ 版本复盘'];
const stepLabel = stepLabels[Number(step)];
if (!stepLabel) { fail(`无效步骤: ${step},应为 1-7`); return; }
// 替换对应步骤的状态
const regex = new RegExp(`(\\| ${stepLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} \\| )(.+?)( \\|)`);
if (regex.test(body)) {
body = body.replace(regex, `$1${status}$3`);
await api.updateIssue(num, { body });
out({ number: Number(num), step, status, updated: true });
if (!opts.options.json) ok(`步骤 ${stepLabel} 已更新为: ${status}`);
} else {
fail(`未在 Issue #${num} 的 body 中找到步骤: ${stepLabel}`);
}
},
};
// ──────── 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());
await test('GET /issues/1', () => api.getIssue(1));
await test('GET /issues/1/comments', () => api.listComments(1));
await test('GET /issues/1/dependencies', () => api.listDependencies(1));
await test('GET /issues/1/blocks', () => api.listBlocks(1));
await test('POST 创建含中文 Issue', async () => {
const i = await api.createIssue({
title: '测试 Issue — UTF-8 验证 🎮',
body: '这是中文内容测试。\n\n- 列表项\n- 特殊字符:✅❌🔴⬜\n\n创建时间: ' + new Date().toISOString(),
});
// 立即关闭测试 Issue
await api.closeIssue(i.number);
return `#${i.number}: ${i.title}`;
});
await test('POST 创建含中文评论', async () => {
const c = await api.createComment(1, '🧪 API 评论测试 — 中文编码验证 ' + new Date().toISOString().slice(0, 19));
return `comment_id: ${c.id}`;
});
await test('PATCH 更新 Issue', async () => {
// 读取 Issue #2 的当前标题,更新后再还原
const orig = await api.getIssue(2);
const newTitle = orig.title + ' [测试更新]';
await api.updateIssue(2, { title: newTitle });
await api.updateIssue(2, { title: orig.title }); // 还原
return 'Issue #2 标题更新并还原';
});
await test('PATCH 关闭/重开 Issue', async () => {
// 创建临时 Issue 测试
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}) 已创建并清理`;
});
await test('POST Issue 添加标签', async () => {
// 获取已有 labels,添加到 Issue #1 再移除
const labels = await api.listLabels();
if (!labels.length) return '无可用标签,跳过';
await api.addIssueLabels(1, [labels[0].id]);
return `添加标签 ${labels[0].name} 到 Issue #1`;
});
// P1: 核心功能
outText('\n── P1: 核心功能验证 ──\n');
await test('GET /issues?milestones=X 过滤', async () => {
const ms = await api.findMilestoneByTitle('v0.1-基础交互');
if (!ms) return '未找到 v0.1 里程碑';
const issues = await api.listIssues({ milestones: ms.id });
return `里程碑 ${ms.title} 下有 ${issues.length} 个 Issue`;
});
await test('POST/DELETE Issue 依赖', async () => {
// Issue #1 block #2,然后移除
await api.addBlock(1, 2);
await api.removeBlock(1, 2);
return '依赖关系添加→移除';
});
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() {
console.log(`
ChildHood Gitea CLI — 项目进度管理工具
用法: node scripts/gitea.js <command> [subcommand] [options]
全局选项:
--help 显示帮助信息
--json 以 JSON 格式输出(供 Agent 脚本调用)
--role <pm|designer|dev> 使用对应角色的 Token(从 scripts/.gitea-tokens.json 读取)
命令列表:
issue list 列出 Issues
[--state open|closed|all] [--milestone <id|标题>] [--labels <id,...>]
issue get <#> 查看 Issue 详情
issue create 创建 Issue
--title "标题" [--body "内容"] [--milestone <id>] [--labels <id,...>]
issue update <#> 更新 Issue
[--title "…"] [--body "…"] [--state open|closed] [--milestone <id>]
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 初始化标准标签体系(status/pipeline/role/gate 共 23 个)
pipeline init 创建 Pipeline Tracker Meta Issue [--title "…"] [--milestone <id>]
pipeline status 查看当前流水线进度
pipeline update <#> 更新流水线步骤 --step <1-7> --status "状态描述"
test 验证所有 API 接口(P0 阻塞性 + P1 核心功能)
Token 管理:
--role <pm|designer|dev> 从 scripts/.gitea-tokens.json 自动读取对应角色的 Token
GITEA_TOKEN 环境变量 手动覆盖(优先级最高)
两者都不提供时报错
示例:
node scripts/gitea.js --role pm issue list --milestone v0.1-基础交互
node scripts/gitea.js --role designer issue comment 2 --body "汇报内容"
node scripts/gitea.js --role dev pipeline status
GITEA_TOKEN=xxx node scripts/gitea.js test
`);
}
// ═══════════════════════════════════════════════════
// 路由与入口
// ═══════════════════════════════════════════════════
let globalArgs;
// ====== Token 文件读取 ======
const path = require('path');
/**
* 从 scripts/.gitea-tokens.json 读取指定角色的 Token
* @param {string} role - 角色名(pm / designer / dev)
* @returns {string|null} Token 或 null
*/
function loadTokenFromFile(role) {
const tokenFile = path.join(__dirname, '.gitea-tokens.json');
try {
const fs = require('fs');
const data = JSON.parse(fs.readFileSync(tokenFile, 'utf-8'));
return data[role] || null;
} catch {
return null;
}
}
/**
* 获取 Token(优先级:环境变量 > --role 文件 > 报错)
* @param {object} options - 解析后的 CLI options
* @returns {string} Token
*/
function resolveToken(options) {
// 优先级 1:环境变量
if (process.env.GITEA_TOKEN) return process.env.GITEA_TOKEN;
// 优先级 2:--role 参数
const role = options.role;
if (role) {
const validRoles = ['pm', 'designer', 'dev', 'artist'];
if (!validRoles.includes(role)) {
console.error(`❌ 无效角色: ${role},可选: ${validRoles.join(', ')}`);
process.exit(1);
}
const token = loadTokenFromFile(role);
if (!token) {
console.error(`❌ 未在 scripts/.gitea-tokens.json 中找到角色 "${role}" 的 Token`);
console.error(' 请创建 scripts/.gitea-tokens.json,格式:');
console.error(' { "pm": "token1", "designer": "token2", "dev": "token3" }');
process.exit(1);
}
return token;
}
// 都没有
console.error('❌ 缺少认证信息。请使用以下方式之一:');
console.error(' --role pm 从 scripts/.gitea-tokens.json 读取 Token');
console.error(' GITEA_TOKEN=xxx 通过环境变量传入');
process.exit(1);
}
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);
const api = new GiteaAPI(token);
// 路由
const commandGroup = cmd[args.command];
if (!commandGroup) {
console.error(`❌ 未知命令: ${args.command}`);
console.error(' 运行 --help 查看可用命令');
process.exit(1);
}
// 特殊: test 是单级命令
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);
});