feat(ticket-management): Gitea 工单「人读+机读」管理技能(标签零新增 · 标题【问题域】前缀 · 正文首行机读 · 正文自包含 · 末尾机读引用块)+ bin/ticket.mjs(labels/milestones/list/verify/create/rename,含六项断言与建单自检)

This commit is contained in:
2026-09-22 15:48:52 +08:00
commit ade5ae5810
4 changed files with 308 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
---
name: ticket-management
description: 用 Gitea 工单库做「人读 + 机读」双面管理 —— 标签零新增;标题用【问题域】前缀(闸门仅「不能动」时作第二段);正文**首行一行机读字段**(域/闸门/优先级/类型/模式/端/环境/证据);正文**自包含**(禁本地路径与内部分析编号,源码用仓库相对路径 + 该 ref 的 permalink);来源只能进**末尾机读引用块**(须写明 ref-owner)。含 bin/ticket.mjs 的 labels/milestones/list/verify/create/rename。适用于:建单、批量规范化既有工单标题、校验工单是否合规、用 API 读写 Gitea issue。
---
# ticket-management(Gitea 工单管理)
> **定位**:执行方法(怎么做),不含项目结论。
> **一句话**:**标签给人(少而稳)· 正文块给机器(全而准)· 标题给人扫(问题域 + 能否动)**。
## 0 什么时候用 / 不用
**用**:建单;批量规范化既有工单的标题 / 首行;校验工单是否符合本规范;用 API 读标签 / 里程碑 / 工单。
**不用**:拿工单做排期与资源计划(那是项目管理);把工作区内部知识库路径写进工单(见 §1 硬规则 3)。
## 1 三条硬规则(不合规就不建单)
1. **标签零新增** —— 只用仓库既有标签;**不得**为分类新建标签(标签是给人扫的稀缺资源,词表一膨胀人就不再用了)。
2. **标题 = `【问题域】` + 可选 `【闸门】`** —— 闸门**只在「当前不能开工」时**出现;能开工就不加(默认即常态,零打扰)。
3. **正文自包含** —— 人读部分**不得**出现工作区 / 本地路径(`codes/`、`分析/`、`C:\…`、`/home/…`)或内部分析编号;源码引用一律 **仓库相对路径 + 该 ref 的 permalink**。
## 2 标题规范
- **主前缀(必填,7 值)**:`【订单】` `【库存】` `【结算】` `【商品】` `【报表】` `【平台】` `【其他】`
- 词表**可按项目替换**,但**改动须登记**;新域先用 `【其他】`,**同域攒够 3 条再立**新前缀。
- **第二段(可选)**:`【等口径】` `【等取证】` `【等部署】` —— **仅当「不能开工」**。
- **准入三问**(新增任何段都要过):① 列表页会按它筛 / 排吗?② 能否从正文推出(能推就别重复占位)?③ 生命周期里会变几次(反复变 = 每次都要改标题)?
- 按此三问:**问题域、闸门**通过;**优先级**(churn 大)· **端** · **环境** · **版本** · **客户 / 店铺**(敏感)· **批次**(用 milestone)全部不通过。
## 3 正文规范
### 3.1 首行 = 机读行(也是人读的第一行)
域=L1/L2/L3 | 闸门=… | 优先级=… | 类型=… | 模式=… | 端=… | 环境=… | 证据=…
- **字段顺序固定**;分隔符 = 空格 + 竖线 + 空格。
- **`域` 的 L1 必须等于标题主前缀**(机器可校验);`域` 三段逐级更细:**L1 受控**(= 标题词表)· **L2 受控小词表** · **L3 自由**(表 / 文件 / 函数 / 接口)。
- 枚举建议:`闸门`=可开工|等口径|等取证|等部署 · `优先级`=立即|本迭代|排队 · `类型`=安全|数据|性能|可用|债务 · `端`=后端|前端|小程序|运维 · `环境`=正式服|测服 · `证据`=E1|E2|需生产取证。
- 若标题出现第二段,**必须与 `闸门=` 一致**。
### 3.2 正文其余(人读)
现象 → 证据锚点(仓库路径 + permalink)→ 复现(只读命令,仓库内执行)→ 影响面 → 反证条件
### 3.3 末尾 = 机读引用块(人读渲染不可见)
```html
<!-- 机器可读 · 引用登记(正文已自包含,本段不参与人读)
ref-owner: <引用对象的拥有者,例如:某分析工作区 / 某会议纪要库>
refs:
- <外部编号或文件>
-->
```
- **必须**写明 `ref-owner`(**引用对象的拥有者**)—— 读者据此知道该去哪儿找、以及**它不在这条工单所在的仓里**。
- **没有外部引用就整段不要**;也不要把内部编号写进人读部分。
## 4 路径口径
| | 写法 |
|---|---|
| ✅ 源码(本仓) | `path/to/file.go:105` + `https://<host>/<owner>/<repo>/src/commit/<sha>/<path>#L105` |
| ✅ 依赖 / 框架(**另一个仓**) | 另标仓:`https://<host>/<owner>/<repo>/src/tag/<tag>/<path>#L829` |
| ⛔ 禁用 | 本地物化前缀(`codes/` · `vendor/` 副本)、工作区路径(`分析/`)、内部分析编号 |
⚠ **行号必须按所引 ref 复核** —— 本地克隆常落后远端(实测:本地 `f5b74b2` 落后远端 `main` `c210c293`)。做法:用 API 取该 ref 的文件内容,再在内容里定位行号。
## 5 工具 `bin/ticket.mjs`
```bash
# 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显)
node bin/ticket.mjs labels --repo xbc/xbc
node bin/ticket.mjs milestones --repo xbc/xbc
node bin/ticket.mjs list --repo xbc/xbc --state=open
node bin/ticket.mjs verify 142 --repo xbc/xbc
node bin/ticket.mjs create --repo xbc/xbc --title='【平台】…' --body-file=body.md --label=77 [--dry-run]
node bin/ticket.mjs rename 142 --repo xbc/xbc --domain=平台 [--gate=等口径] [--dry-run]
```
| 子命令 | 作用 | 需要 scope |
|---|---|---|
| `labels` / `milestones` / `list` | 读(标签 id 是创建时唯一可传的形式) | `read:issue` |
| `verify <n>` | **校验**(只读):见 §7 六项断言 | `read:issue` |
| `create` | 建单(**先按标题查重**) | `write:issue` |
| `rename <n>` | 规范化标题(重建前缀,描述不动) | `write:issue` |
## 6 常见坑(实测)
- **HTTP 分块解码**:`let d = ''; res.on('data', c => d += c)` 会把**跨块的多字节字符**打成 `U+FFFD`(中文标签名就被打坏过)⇒ 必须 `res.setEncoding('utf8')`,或用 `fetch` 的 `text()`。
- **建单前必须查重**:按标题查,否则会重复建单。
- **标签传 id**:`POST /issues` 的 `labels` 是**标签 id 数组**,不是名字。
- **改标题与正文**:都是 `PATCH /repos/{owner}/{repo}/issues/{n}`。
- **令牌 scope**:只读 `read:issue,read:repository` 建不了单(403,报错会写 required scope);建 / 改需 `write:issue`;`GET /user` 另需 `read:user`。
- **里程碑可能已被当「状态桶」用**(如「记录工单 / 领取工单 / 完结工单」)⇒ 不要再用它承载闸门,否则两套状态打架;用前先 `milestones` 看语义与 `due_on`。
- **没有看板时**(`/projects` 返回 404)不要设计「用看板列承载闸门」的流程。
## 7 `verify` 的六项断言(可交机器)
1. 标题以 `【…】` 开头,且内文 ∈ 问题域词表;
2. 正文第一行存在且含 `域=`;
3. **标题主域 = 首行 `域=` 的 L1**;
4. 标题若有第二段 ⇒ ∈ 闸门词表 **且** 等于首行 `闸门=`;
5. 人读部分(第一个 `<!--` 之前)**不含** `codes/` · `分析/` · `86 §` · `80 · V` · 盘符路径;
6. 正文若含 `<!--` ⇒ 该注释块必须含 `ref-owner:`。
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
// ticket.mjs — Gitea 工单工具(人读 + 机读规范)
// 子命令:labels | milestones | list | verify <n...> | create | rename <n>
// 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显)
import fs from "node:fs";
const DOMAINS = ["订单", "库存", "结算", "商品", "报表", "平台", "其他"];
const GATES = ["等口径", "等取证", "等部署"];
const NL = String.fromCharCode(10);
const BACKSLASH = String.fromCharCode(92);
const BAD = ["codes/", "分析/", "86 §", "80 · V", "/home/", "C:" + BACKSLASH];
function parseArgs(argv) {
const pos = [], opt = {};
for (const a of argv) {
if (a.startsWith("--")) { const i = a.indexOf("="); if (i < 0) opt[a.slice(2)] = true; else opt[a.slice(2, i)] = a.slice(i + 1); }
else pos.push(a);
}
return { pos: pos, opt: opt };
}
function readToken(opt) {
if (process.env.GITEA_TOKEN) return process.env.GITEA_TOKEN.trim();
const f = opt["token-file"];
if (!f) throw new Error("缺令牌:设 GITEA_TOKEN 或 --token-file=<文件>");
for (const line of fs.readFileSync(f, "utf8").split(NL)) {
if (/token/i.test(line)) { const i = line.indexOf("="); if (i > 0) return line.slice(i + 1).split("#")[0].trim(); }
}
throw new Error("令牌文件里没找到 token = ...");
}
function mkApi(opt) {
const host = opt.host || "code.hoteas.com";
const H = { Authorization: "token " + readToken(opt), "Content-Type": "application/json", Accept: "application/json", "User-Agent": "agents-skills/ticket-management" };
return async function api(path, method, body) {
const r = await fetch("https://" + host + "/api/v1" + path, { method: method || "GET", headers: H, body: body ? JSON.stringify(body) : undefined });
const t = await r.text();
let j = null; try { j = JSON.parse(t); } catch (e) {}
return { s: r.status, total: r.headers.get("x-total-count"), j: j, t: t };
};
}
function splitTitle(title) {
const brackets = []; let s = String(title || "").trim();
while (s.startsWith("【")) { const e = s.indexOf("】"); if (e < 0) break; brackets.push(s.slice(1, e)); s = s.slice(e + 1).trim(); }
return { brackets: brackets, rest: s };
}
function firstLine(body) { return String(body || "").split(NL)[0] || ""; }
function fieldOf(line, key) {
for (const p of String(line).split(" | ")) { const i = p.indexOf("="); if (i > 0 && p.slice(0, i).trim() === key) return p.slice(i + 1).trim(); }
return "";
}
function humanPart(body) { const b = String(body || ""); const i = b.indexOf("<!--"); return i < 0 ? b : b.slice(0, i); }
// 六项断言(verify 与 create 共用)
function checksOf(title, body) {
const sp = splitTitle(title), L = firstLine(body);
const dom = fieldOf(L, "域"), gate = fieldOf(L, "闸门"), hp = humanPart(body);
return [
["1 标题主前缀 ∈ 词表", sp.brackets.length > 0 && DOMAINS.indexOf(sp.brackets[0]) >= 0],
["2 首行含 域=", L.indexOf("域=") >= 0],
["3 标题主域 = 首行 域 的 L1", sp.brackets.length > 0 && dom.split("/")[0] === sp.brackets[0]],
["4 第二段 = 闸门=", sp.brackets.length < 2 ? true : (GATES.indexOf(sp.brackets[1]) >= 0 && sp.brackets[1] === gate)],
["5 人读部分无本地 / 外部引用", !BAD.some(function (b) { return hp.indexOf(b) >= 0; })],
["6 有引用块 ⇒ 有 ref-owner", body.indexOf("<!--") < 0 ? true : body.indexOf("ref-owner:") >= 0]
];
}
async function findTitle(api, repo, title) {
const r = await api("/repos/" + repo + "/issues?state=all&type=issues&limit=100");
return (Array.isArray(r.j) ? r.j : []).filter(function (x) { return String(x.title || "") === title; });
}
async function cmdRead(api, opt, kind) {
const r = await api("/repos/" + opt.repo + "/" + kind + "?limit=100&state=all");
if (r.s !== 200) { console.log("读取失败 status=" + r.s + " " + r.t.slice(0, 120)); return 1; }
const arr = Array.isArray(r.j) ? r.j : [];
console.log(kind + " 共 " + arr.length + " 项");
for (const x of arr) {
if (kind === "labels") console.log(" #" + x.id + " " + x.name + " color=" + x.color);
else console.log(" #" + x.id + " " + x.title + " open=" + x.open_issues + " closed=" + x.closed_issues + " due=" + (x.due_on || "-"));
}
return 0;
}
async function cmdList(api, opt) {
const state = opt.state || "open";
const r = await api("/repos/" + opt.repo + "/issues?state=" + state + "&type=issues&limit=" + (opt.limit || 30));
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
const arr = Array.isArray(r.j) ? r.j : [];
console.log("issues(" + state + ") 返回 " + arr.length + " · 总数 " + (r.total || "-"));
for (const it of arr) console.log(" #" + it.number + " [" + (it.labels || []).map(function (l) { return l.name; }).join(",") + "] " + it.title);
return 0;
}
async function cmdVerify(api, opt, nums) {
let fail = 0, total = 0;
for (const n of nums) {
const r = await api("/repos/" + opt.repo + "/issues/" + n);
if (r.s !== 200) { console.log("#" + n + " 读取失败 status=" + r.s); fail++; continue; }
console.log("#" + n + " " + r.j.title);
for (const c of checksOf(String(r.j.title || ""), String(r.j.body || ""))) {
total++; if (!c[1]) fail++;
console.log(" " + (c[1] ? "PASS" : "FAIL") + " " + c[0]);
}
}
console.log("断言 " + (total - fail) + "/" + total + " 通过" + (fail ? " · FAIL " + fail : ""));
return fail ? 1 : 0;
}
async function cmdCreate(api, opt) {
const title = opt.title;
if (!title) { console.log("用法:create --repo=o/r --title=... --body-file=... [--label=<id>] [--dry-run] [--force]"); return 1; }
const sp = splitTitle(title);
if (!sp.brackets.length || DOMAINS.indexOf(sp.brackets[0]) < 0) { console.log("拒绝:标题主前缀不合规 —— " + title); return 1; }
const dup = await findTitle(api, opt.repo, title);
if (dup.length) { console.log("拒绝:已存在同名单 #" + dup[0].number + " —— 先查重是硬规则"); return 1; }
const bodyFile = opt["body-file"];
if (!bodyFile) { console.log("用法:create ... --body-file=..."); return 1; }
const body = fs.readFileSync(bodyFile, "utf8");
const bad = checksOf(title, body).filter(function (c) { return !c[1]; });
if (bad.length && !opt.force) {
console.log("拒绝:正文/标题未过自检(" + bad.length + " 项)");
for (const c of bad) console.log(" FAIL " + c[0]);
console.log("(确认要建就加 --force)");
return 1;
}
if (opt["dry-run"]) { console.log("[dry-run] 将建单:" + title + " · 正文 " + body.length + " 字 · 自检 " + (6 - bad.length) + "/6"); return 0; }
const payload = { title: title, body: body };
if (opt.label) payload.labels = [Number(opt.label)];
const r = await api("/repos/" + opt.repo + "/issues", "POST", payload);
console.log("status=" + r.s + (r.s === 201 ? " → #" + r.j.number + " " + r.j.html_url : " " + r.t.slice(0, 200)));
return r.s === 201 ? 0 : 1;
}
async function cmdRename(api, opt, n) {
const r = await api("/repos/" + opt.repo + "/issues/" + n);
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
const sp = splitTitle(r.j.title);
const dom = opt.domain;
if (!dom || DOMAINS.indexOf(dom) < 0) { console.log("用法:rename <n> --domain=<词表值> [--gate=等口径|等取证|等部署] [--dry-run]"); return 1; }
let title = "【" + dom + "】";
if (opt.gate && opt.gate !== "可开工") {
if (GATES.indexOf(opt.gate) < 0) { console.log("闸门不在词表:" + opt.gate); return 1; }
title += "【" + opt.gate + "】";
}
title += sp.rest;
if (opt["dry-run"]) { console.log("[dry-run] " + r.j.title + " → " + title); return 0; }
const p = await api("/repos/" + opt.repo + "/issues/" + n, "PATCH", { title: title });
console.log("status=" + p.s + " → " + (p.j && p.j.title ? p.j.title : p.t.slice(0, 160)));
return p.s === 201 ? 0 : 1;
}
const A = parseArgs(process.argv.slice(2));
const cmd = A.pos[0];
if (!cmd || cmd === "-h" || cmd === "--help") { console.log("用法:ticket.mjs <labels|milestones|list|verify|create|rename> --repo=owner/name [...]"); process.exit(0); }
if (!A.opt.repo) { console.log("缺 --repo=owner/name"); process.exit(1); }
const api = mkApi(A.opt);
let code = 0;
if (cmd === "labels" || cmd === "milestones") code = await cmdRead(api, A.opt, cmd);
else if (cmd === "list") code = await cmdList(api, A.opt);
else if (cmd === "verify") code = await cmdVerify(api, A.opt, A.pos.slice(1));
else if (cmd === "create") code = await cmdCreate(api, A.opt);
else if (cmd === "rename") code = await cmdRename(api, A.opt, A.pos[1]);
else { console.log("未知子命令:" + cmd); code = 1; }
process.exit(code);
+22
View File
@@ -0,0 +1,22 @@
域=<L1>/<L2>/<L3> | 闸门=<可开工|等口径|等取证|等部署> | 优先级=<立即|本迭代|排队> | 类型=<安全|数据|性能|可用|债务> | 模式=<…|全模式> | 端=<后端|前端|小程序|运维> | 环境=<正式服|测服> | 证据=<E1|E2|需生产取证>
**现象**:<一句话说清「谁在什么场景下会看到什么坏结果」>
**证据锚点(仓库 <owner>/<repo> @ <sha8>)**
- <path>:<line> —— <该行代码 / 配置原文片段>
<permalink>
**复现(只读 · 在仓库根执行)**
- <只读命令>
**影响面**
- <受影响对象 / 计数 / 频率>
**反证条件**
- <出现什么就说明本判定不成立>
<!-- 机器可读 · 引用登记(正文已自包含,本段不参与人读)
ref-owner: <外部资料拥有者>
refs:
- <外部编号>
-->