Files
agents-skills/ticket-management/bin/ticket.mjs
T
xuxiang a70eaae125 feat(ticket-management)!: 「任务全貌」四段 → 五段,新增必备段「经验教训」
用户 2026-09-24 指出漏了一个要点:**经验教训**也应是**重点结构**的一部分。

【改动】
- SKILL §8.1:正文骨架加第 ⑤ 段 `## 经验教训`(**必备**,排在 `效果对比` 之后)——
  必须是**可复用结论**(对象 + 动作 / 判据);**尚未定性写「暂无(原因)」**,
  **不许留空壳,也不许写「加强测试」这类套话**。
- §8.2 闸门 B:**四问 → 五问**(新增「它给出『下次怎么避免 / 怎么做』的可复用结论吗?」)。
- §8.3 整洁性自检:加「经验教训须是可复用结论、非套话;未定性须显式写『暂无(原因)』」。
- §3.2 骨架 / §8.0 定全貌 / §8.1 标题 / §5 assess 表 / §7 分工注 / §8.3 有效性 —— 措辞全部同步。
- `bin/ticket.mjs`:`SECTIONS` 加第 5 段(词表 `经验教训` / `教训` / `复盘`);`assess` 表头改「五段」;
  **必备段缺失提示按段区分**(教训 ⇒ 指「暂无(原因)」;提案 ⇒ 指「结论即提案 / 无需处置」);
  `selftest` 20 → **21**。
- `templates/issue.md` 加 `## 经验教训`;README 技能行改「五段任务全貌」。
- (顺带更正)README 里「本机已按此接好 ticket-management」**与实测不符**(本机无该 junction)⇒
  去掉机器特有断言 —— 可复用文档不写本地状态。

【验证 · E1 · 本机】
- `node --check` 通过;`selftest` **21/21**;`grep 四段|四问` ⇒ **0 残留**。
- `assess 158`(真跑)⇒ 五段中**恰好第 ⑤ 段未识别**(正是用户指出的缺口,`#158` 待补经验教训);
  顺序 OK · 空壳 无。
- 合成「五段齐」正文 ⇒ 五段全 ✓ · 顺序 OK · 空壳 无。
2026-09-24 10:50:33 +08:00

437 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// ticket.mjs — Gitea 工单工具(人读 + 机读规范 · **结构评估先行**)
// 子命令:labels | milestones | list | verify <n...> | assess <n|--body-file=f> | create
// update <n> | comments <n> | comment-add <n> | comment-del <id> | rename <n>
// 令牌:GITEA_TOKEN 环境变量,或 --token-file=<含 key=value 的文件>(不落盘、不回显)
// 纪律真源:同目录 ../SKILL.md(§7 verify 七断言 · §8 结构纪律 / assess 五段体检)
import fs from "node:fs";
const DOMAINS = ["订单", "库存", "结算", "商品", "报表", "平台", "其他"];
const GATES = ["等口径", "等取证", "等部署"];
const NL = String.fromCharCode(10);
const BACKSLASH = String.fromCharCode(92);
const BAD = ["codes/", "分析/数据/", "分析/D-", "分析/E-", "分析/A-", "分析/B-", "分析/C-", "分析/F-", "分析/_tmp", "分析/_archive", "分析/README", "86 §", "80 · V", "/home/", "C:" + BACKSLASH];
// §8.1「任务全貌」五段:正文骨架词表(数组顺序 = 规范顺序)
const SECTIONS = [
{ key: "问题背景", words: ["现象", "根因", "影响面", "问题背景", "背景", "生产实测"], need: "必备" },
{ key: "处置提案", words: ["处置提案", "方案", "机制", "提案", "路线", "结论"], need: "必备" },
{ key: "处置结果", words: ["处置结果", "执行回执", "当前状态", "回执", "结果"], need: "按事实" },
{ key: "效果对比", words: ["效果对比", "收益验证", "收益", "对照", "效果", "基线"], need: "按事实" },
{ key: "经验教训", words: ["经验教训", "教训", "复盘"], need: "必备" }
];
// 五段之外「已识别的小节」:不计入五段,但也不该被报成「未归类」
const OPTIONAL_SECTIONS = ["验收判据", "反证条件", "遗留", "取证记录"];
// 取值型选项:`--repo=o/r` 与 `--repo o/r` 两种写法都收(等价;布尔开关不吃后一个参数)
const VALUE_OPTS = ["repo", "title", "body-file", "label", "milestone", "token-file", "host", "state", "limit", "domain", "gate", "dump"];
function parseArgs(argv) {
const pos = [], opt = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) { pos.push(a); continue; }
const j = a.indexOf("=");
if (j >= 0) { opt[a.slice(2, j)] = a.slice(j + 1); continue; }
const k = a.slice(2), next = argv[i + 1];
if (VALUE_OPTS.indexOf(k) >= 0 && next !== undefined && !next.startsWith("--")) { opt[k] = next; i++; }
else opt[k] = true;
}
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";
let H = null; // 延迟取令牌 ⇒ assess --body-file 无需令牌
return async function api(path, method, body) {
if (!H) H = { Authorization: "token " + readToken(opt), "Content-Type": "application/json", Accept: "application/json", "User-Agent": "agents-skills/ticket-management" };
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(); }
const parts = brackets.length ? brackets[0].split("-").map(function (x) { return x.trim(); }) : [];
return { brackets: brackets, parts: parts, 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/update 共用)
function checksOf(title, body) {
const sp = splitTitle(title), L = firstLine(body);
const dom = fieldOf(L, "域"), gate = fieldOf(L, "闸门"), hp = humanPart(body);
return [
["1 标题主前缀 ∈ 词表", sp.parts.length > 0 && DOMAINS.indexOf(sp.parts[0]) >= 0],
["2 首行含 域=", L.indexOf("域=") >= 0],
["3 标题主域 = 首行 域 的 L1", sp.parts.length > 0 && dom.split("-")[0] === sp.parts[0]],
["4 标题闸门 = 首行 闸门=", sp.parts.length < 2 ? (gate === "可开工") : (GATES.indexOf(sp.parts[1]) >= 0 && sp.parts[1] === gate)],
["5 人读部分无本地 / 外部引用", !BAD.some(function (b) { return hp.indexOf(b) >= 0; })],
["6 有引用块 ⇒ 有 ref-owner", body.indexOf("<!--") < 0 ? true : body.indexOf("ref-owner:") >= 0]
];
}
// —— §8.0 结构评估(只读)——
function headingsOf(body) {
const out = [];
for (const line of humanPart(body).split(NL)) {
const m = /^(#{1,6})\s+(.*)$/.exec(line);
if (m) out.push({ level: m[1].length, text: m[2].trim() });
}
return out;
}
function sectionIndex(text) {
// 最长词优先(更具体者胜):如「变更后收益验证(…方案)」应归「效果对比」而非「处置提案」
let best = -1, bestLen = 0;
for (let i = 0; i < SECTIONS.length; i++) {
for (const w of SECTIONS[i].words) {
if (text.indexOf(w) >= 0 && w.length > bestLen) { best = i; bestLen = w.length; }
}
}
return best;
}
function isOptional(text) { return OPTIONAL_SECTIONS.some(function (w) { return text.indexOf(w) >= 0; }); }
function structureOf(body) {
const lines = humanPart(body).split(NL);
const heads = [];
lines.forEach(function (l, i) {
const m = /^(#{1,6})\s+(.*)$/.exec(l);
if (m) heads.push({ pos: i, level: m[1].length, text: m[2].trim() });
});
// 空壳判定:某标题之后、下一个「同级或更高级」标题之前无任何内容(含子标题)⇒ 空壳
const empty = [];
for (let k = 0; k < heads.length; k++) {
let j = k + 1;
while (j < heads.length && heads[j].level > heads[k].level) j++;
const inner = lines.slice(heads[k].pos + 1, j < heads.length ? heads[j].pos : lines.length)
.join(NL).replace(/<!--[\s\S]*?-->/g, "").trim();
if (!inner) empty.push(lines[heads[k].pos].trim());
}
// 归类只看 ## 级;「更正 / 补充」小节允许后置,不计入顺序
// 归类只看 ## 级;顺序按「主叙事」如实判定(「更正 / 补充」小节若后置会如实报出,由人判断)
const tops = heads.filter(function (h) { return h.level === 2; })
.map(function (h) { return { text: h.text, idx: sectionIndex(h.text) }; });
const hits = tops.filter(function (t) { return t.idx >= 0; });
const order = hits.map(function (t) { return t.idx; });
const ordered = order.every(function (v, i) { return i === 0 || order[i - 1] <= v; });
const unclassified = tops.filter(function (t) { return t.idx < 0 && !isOptional(t.text); }).map(function (t) { return t.text; });
const optional = OPTIONAL_SECTIONS.filter(function (w) { return heads.some(function (h) { return h.text.indexOf(w) >= 0; }); });
return { hits: hits, ordered: ordered, empty: empty, optional: optional, unclassified: unclassified };
}
function printStructure(body) {
const st = structureOf(body);
console.log("五段(§8.1 · 顺序 " + SECTIONS.map(function (s) { return s.key; }).join(" → ") + "):");
for (let i = 0; i < SECTIONS.length; i++) {
const own = st.hits.filter(function (x) { return x.idx === i; }).map(function (x) { return "## " + x.text; });
const tail = own.length ? " " + own.join(" · ")
: (SECTIONS[i].need === "必备"
? " ← 未识别到(必备段:" + (SECTIONS[i].key === "经验教训"
? "若尚未定性,写「暂无(原因)」;不许留空壳,也不许写「加强测试」这类套话"
: "确认是否用了同义小标题;若「结论即提案」或结论为「无需处置」,请显式写一段声明") + ")"
: " ← 未识别到(未发生 / 未测就不写,不要空壳)");
console.log(" " + (own.length ? "[✓]" : "[ ]") + " " + SECTIONS[i].key + "(" + SECTIONS[i].need + ")" + tail);
}
console.log(" 顺序:" + (st.ordered ? "OK" : "⚠ 主叙事未按 背景→提案→结果→效果 排列 ⇒ 调整(确为「更正 / 补充」后置小节时可保留,由人判断)"));
if (st.unclassified.length) console.log(" 未归类 ## 小节(人工判断归属):" + st.unclassified.map(function (t) { return "## " + t; }).join(" · "));
console.log(" 空壳段:" + (st.empty.length ? "✗ " + st.empty.join(" · ") + "(删掉或补齐)" : "无"));
console.log(" 其他已识别小节(不计入五段):" + (st.optional.length ? st.optional.join(" · ") : "无"));
}
// 「必须可执行物」的启发式判据(§8.1 第 2 条):含代码围栏或 SQL 关键字 —— 只作提示,仍需人核
const FENCE = String.fromCharCode(96, 96, 96);
function isExecutable(body) {
const b = String(body || "");
return b.indexOf(FENCE) >= 0 || /\b(SELECT|INSERT|UPDATE|DELETE|ALTER|CREATE|DROP|SHOW|EXPLAIN|ANALYZE|DESCRIBE|SET)\b/i.test(b);
}
// 自检(不联网、不需要令牌):node bin/ticket.mjs selftest
function cmdSelftest() {
let pass = 0, fail = 0;
const t = function (name, ok) { if (ok) { pass++; console.log(" PASS " + name); } else { fail++; console.log(" FAIL " + name); } };
// —— 解析器:两种写法等价 · 布尔开关不吃后一个参数 ——
const p1 = parseArgs(["list", "--repo", "xbc/xbc", "--state=open"]);
t("空格写法 --repo xbc/xbc", p1.opt.repo === "xbc/xbc" && p1.pos[0] === "list" && p1.opt.state === "open");
const p2 = parseArgs(["verify", "142", "--repo=xbc/xbc"]);
t("等号写法 --repo=xbc/xbc", p2.opt.repo === "xbc/xbc" && p2.pos[1] === "142");
const p3 = parseArgs(["create", "--repo=xbc/xbc", "--dry-run"]);
t("布尔开关不吃后面的参数", p3.opt["dry-run"] === true && p3.opt.repo === "xbc/xbc");
const p4 = parseArgs(["comments", "158", "--dump", "_bak.md"]);
t("新增取值型选项同规则(--dump)", p4.opt.dump === "_bak.md" && p4.pos[1] === "158");
// —— §7 七项断言:合规样例 + 反例 ——
const ok = checksOf("【报表-等口径】报表未按经营模式分列", "域=报表-统计-模式分列 | 闸门=等口径 | 优先级=本迭代 | 类型=数据 | 模式=改造 | 端=后端 | 环境=测服 | 证据=E1" + NL + NL + "正文");
t("合规样例 6/6", ok.every(function (c) { return c[1]; }));
const b1 = checksOf("【报表】报表未按经营模式分列", "域=报表-统计-模式分列 | 闸门=可开工 | 优先级=本迭代" + NL + NL + "见 codes/foo.go");
t("标题无闸门 + 首行 闸门=可开工 ⇒ 断言 4 通过", b1[3][1] === true);
t("人读部分含禁用前缀 ⇒ 断言 5 失败", b1[4][1] === false);
const b2 = checksOf("【报表-等口径】x", "域=报表-统计-模式分列 | 闸门=可开工");
t("标题带闸门但首行 闸门=可开工 ⇒ 断言 4 失败", b2[3][1] === false);
const b3 = checksOf("【报表】x", "域=报表-统计-模式分列" + NL + NL + "<!-- refs:");
t("有引用块无 ref-owner ⇒ 断言 6 失败", b3[5][1] === false);
// —— §8 结构评估:五段齐 / 缺必备段 / 空壳 / 乱序 ——
const sOk = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "坏。" + NL + NL + "## 方案" + NL + "修。");
t("背景 + 提案齐 ⇒ 顺序 OK", sOk.hits.length === 2 && sOk.ordered === true && sOk.empty.length === 0);
const sMiss = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "坏。");
t("缺必备段(提案)可被识别", sMiss.hits.some(function (x) { return x.idx === 1; }) === false);
const sEmpty = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "坏。" + NL + NL + "## 处置结果" + NL + NL + "## 遗留" + NL + "x");
t("空壳段可被识别", sEmpty.empty.length === 1);
const sOrder = structureOf("域=平台-性能-索引" + NL + NL + "## 效果对比" + NL + "a" + NL + NL + "## 现象" + NL + "b");
t("乱序可被识别", sOrder.ordered === false);
// —— 复核采纳:结论即提案 / 基线归效果 / 取证记录不再误报 / 可执行物启发式 ——
const sConclusion = structureOf("域=平台-性能-分区" + NL + NL + "## 现象" + NL + "坏。" + NL + NL + "## 结论:与索引的分工" + NL + "分工。");
t("「结论即提案」可被识别为提案", sConclusion.hits.some(function (x) { return x.idx === 1; }) === true);
const sBaseline = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "x" + NL + NL + "## 生产基线 T0" + NL + "y");
t("「基线」可被识别为效果对比", sBaseline.hits.some(function (x) { return x.idx === 3; }) === true);
const sEvid = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "x" + NL + NL + "## 取证记录" + NL + "y");
t("「取证记录」不再报成未归类", sEvid.unclassified.length === 0 && sEvid.optional.indexOf("取证记录") >= 0);
const sDetail = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "x" + NL + NL + "## 方案明细(… 实测代价)" + NL + "y");
t("「方案明细(…实测代价)」归提案(不被『实测』抢走)", sDetail.hits.some(function (x) { return x.idx === 1; }) === true);
t("可执行物启发式:代码围栏", isExecutable(FENCE + "bash" + NL + "echo hi" + NL + FENCE) === true);
t("可执行物启发式:SQL 关键字", isExecutable("跑这条 SELECT 即可") === true);
t("可执行物启发式:过程叙述判非可执行", isExecutable("今天讨论了一下,暂时没有结论") === false);
const sLesson = structureOf("域=平台-性能-索引" + NL + NL + "## 现象" + NL + "x" + NL + NL + "## 经验教训" + NL + "y");
t("「经验教训」可被识别为第 5 段", sLesson.hits.some(function (x) { return x.idx === 4; }) === true);
console.log("自检 " + pass + "/" + (pass + fail));
return fail === 0 ? 0 : 1;
}
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]);
}
const labNames = (r.j.labels || []).map(function (l) { return l.name; });
total++; if (!labNames.length) fail++;
console.log(" " + (labNames.length ? "PASS" : "FAIL") + " 7 已带标签" + (labNames.length ? " [" + labNames.join(",") + "]" : " (空 ⇒ 建单时未传 --label)"));
}
console.log("断言 " + (total - fail) + "/" + total + " 通过" + (fail ? " · FAIL " + fail : ""));
return fail ? 1 : 0;
}
async function cmdAssess(api, opt, n) {
let title = "", body = "", comments = null;
if (opt["body-file"]) {
body = fs.readFileSync(opt["body-file"], "utf8");
title = opt.title ? String(opt.title) : "(未给 --title,只评正文)";
} else {
if (!n) { console.log("用法:assess <n> --repo=o/r | assess --body-file=<文件> [--title=…]"); return 1; }
const r = await api("/repos/" + opt.repo + "/issues/" + n);
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
title = String(r.j.title || ""); body = String(r.j.body || "");
const rc = await api("/repos/" + opt.repo + "/issues/" + n + "/comments?limit=100");
comments = Array.isArray(rc.j) ? rc.j : [];
}
console.log("== 结构评估(§8.0 落点表底稿 · 只报结构,判断仍由你下)==");
console.log("标题:" + title);
console.log("首行机读:" + (firstLine(body) || "(空)"));
printStructure(body);
if (comments) {
console.log("评论清单(" + comments.length + ")—— 只应剩「必须可执行物」(§8.1 第 2 条):");
for (const c of comments) {
const b = String(c.body || "");
console.log(" " + c.id + " " + b.length + " 字 " + (isExecutable(b) ? "[可执行]" : "[? 非可执行]") + " 首行: " + (b.split(NL)[0] || "").slice(0, 90));
}
console.log(" ([可执行] 为**启发式**:含代码围栏或 SQL 关键字 ⇒ 仍需人核)");
if (comments.length > 1) console.log(" ⚠ 多条评论 ⇒ 逐条过 §8.2;删前先备份:comments " + n + " --dump=<文件>(§8.1 第 5 条)");
}
console.log("下一步:把本轮候选信息逐条填进 §8.0 的落点表 ⇒ 再 create / update / comment-add。");
return 0;
}
async function cmdCreate(api, opt) {
const title = opt.title;
if (!title) { console.log("用法:create --repo=o/r --title=... --body-file=... --label=<id>[,<id>] [--dry-run] [--force](--repo o/r 亦可)"); return 1; }
const sp = splitTitle(title);
if (!sp.parts.length || DOMAINS.indexOf(sp.parts[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; }
// 标签必传(2026-09-23 · 用户交办):缺失即拒建,--force 不可跳过(实测 #156/#157 因未传而裸建无标签)
const labelIds = String(opt.label == null ? "" : opt.label).split(",").map(function (x) { return x.trim(); }).filter(Boolean);
if (!labelIds.length) { console.log("拒绝:建单必须带标签 —— 传 --label=<id>[,<id>](标签表:ticket.mjs labels --repo=" + opt.repo + ")"); return 1; }
const badId = labelIds.filter(function (x) { return !/^[0-9]+$/.test(x) || Number(x) <= 0; });
if (badId.length) { console.log("拒绝:--label 只接受正整数 id,收到 " + badId.join(",")); return 1; }
const rl = await api("/repos/" + opt.repo + "/labels?limit=100");
const knownLabels = Array.isArray(rl.j) ? rl.j : [];
const missId = labelIds.filter(function (x) { return !knownLabels.some(function (l) { return String(l.id) === x; }); });
if (knownLabels.length && missId.length) {
console.log("拒绝:标签 id 不存在 —— " + missId.join(",") + ";本仓可选:" + knownLabels.map(function (l) { return l.id + "=" + l.name; }).join(" · "));
return 1;
}
const body = fs.readFileSync(bodyFile, "utf8");
const checks = checksOf(title, body);
const bad = checks.filter(function (c) { return !c[1]; });
console.log("结构评估(§8.0 · 建单前必看):");
printStructure(body);
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 + " 字 · 标签 " + labelIds.join(",") + " · 自检 " + (checks.length - bad.length) + "/" + checks.length); return 0; }
const payload = { title: title, body: body, labels: labelIds.map(Number) };
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 cmdUpdate(api, opt, n) {
if (!n) { console.log("用法:update <n> --repo=o/r --body-file=<文件> [--title=…] [--dry-run] [--force]"); return 1; }
const bodyFile = opt["body-file"];
if (!bodyFile) { console.log("用法:update <n> --body-file=<文件>(改正文;标题与正文同一次提交,§8.1 第 4 条)"); return 1; }
const cur = await api("/repos/" + opt.repo + "/issues/" + n);
if (cur.s !== 200) { console.log("读取失败 status=" + cur.s); return 1; }
const title = opt.title != null ? String(opt.title) : String(cur.j.title || "");
const body = fs.readFileSync(bodyFile, "utf8");
const checks = checksOf(title, body);
const bad = checks.filter(function (c) { return !c[1]; });
console.log("结构评估(§8.0 · 改单前必看):");
printStructure(body);
if (bad.length && !opt.force) {
console.log("拒绝:正文/标题未过自检(" + bad.length + " 项)");
for (const c of bad) console.log(" FAIL " + c[0]);
console.log("(确认要改就加 --force)");
return 1;
}
const payload = { body: body };
if (opt.title != null) payload.title = title;
if (opt["dry-run"]) { console.log("[dry-run] PATCH #" + n + " · 标题" + (opt.title != null ? "(同批改)" : "(不动)") + " · 正文 " + body.length + " 字 · 自检 " + (checks.length - bad.length) + "/" + checks.length); return 0; }
const r = await api("/repos/" + opt.repo + "/issues/" + n, "PATCH", payload);
console.log("status=" + r.s + (r.j && r.j.html_url ? " → " + r.j.html_url : " " + r.t.slice(0, 200)));
return (r.s === 200 || r.s === 201) ? 0 : 1;
}
async function cmdComments(api, opt, n) {
if (!n) { console.log("用法:comments <n> --repo=o/r [--dump=<文件>]"); return 1; }
const r = await api("/repos/" + opt.repo + "/issues/" + n + "/comments?limit=100");
if (r.s !== 200) { console.log("读取失败 status=" + r.s); return 1; }
const arr = Array.isArray(r.j) ? r.j : [];
if (opt.dump) {
const head = "# 工单 #" + n + " 评论备份(" + new Date().toISOString() + ")" + NL + NL +
"> 删除前全量备份(SKILL §8.1 第 5 条:删除前必须备份 · 删除 ≠ 丢失)。共 " + arr.length + " 条。" + NL;
fs.writeFileSync(opt.dump, head + arr.map(function (c) {
return NL + NL + "## comment " + c.id + "(" + String(c.body || "").length + " 字符)" + NL + NL + String(c.body || "");
}).join(NL), "utf8");
console.log("已备份 " + arr.length + " 条 → " + opt.dump);
}
console.log("#" + n + " 评论 " + arr.length + " 条(只应剩「必须可执行物」):");
for (const c of arr) {
const b = String(c.body || "");
console.log(" " + c.id + " " + b.length + " 字 " + (isExecutable(b) ? "[可执行]" : "[? 非可执行]") + " 首行: " + (b.split(NL)[0] || "").slice(0, 90));
}
console.log("([可执行] 为**启发式**:含代码围栏或 SQL 关键字 ⇒ 仍需人核)");
return 0;
}
async function cmdCommentAdd(api, opt, n) {
if (!n || !opt["body-file"]) { console.log("用法:comment-add <n> --repo=o/r --body-file=<文件> [--dry-run]"); return 1; }
const body = fs.readFileSync(opt["body-file"], "utf8");
if (opt["dry-run"]) { console.log("[dry-run] 将给 #" + n + " 加评论 · " + body.length + " 字"); return 0; }
const r = await api("/repos/" + opt.repo + "/issues/" + n + "/comments", "POST", { body: body });
console.log("status=" + r.s + (r.s === 201 && r.j ? " → comment " + r.j.id : " " + r.t.slice(0, 200)));
return r.s === 201 ? 0 : 1;
}
async function cmdCommentDel(api, opt, id) {
if (!id) { console.log("用法:comment-del <commentId> --repo=o/r [--yes](不带 --yes 只预览)"); return 1; }
const g = await api("/repos/" + opt.repo + "/issues/comments/" + id);
const text = g.s === 200 ? String(g.j.body || "") : "";
console.log("comment " + id + (g.s === 200 ? "(" + text.length + " 字)" : "(读取失败 status=" + g.s + ",继续前请自行确认 id)"));
if (text) console.log(text.split(NL).slice(0, 6).map(function (l) { return " | " + l; }).join(NL));
if (!opt.yes) {
console.log("预览模式(未删除)。确认要删加 --yes;**删前先备份**:comments <n> --dump=<文件>(§8.1 第 5 条)。");
return 0;
}
const r = await api("/repos/" + opt.repo + "/issues/comments/" + id, "DELETE");
console.log("DELETE status=" + r.s);
return (r.s === 204 || r.s === 200) ? 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 inner = dom;
if (opt.gate && opt.gate !== "可开工") {
if (GATES.indexOf(opt.gate) < 0) { console.log("闸门不在词表:" + opt.gate); return 1; }
inner = dom + "-" + opt.gate;
}
const title = "【" + inner + "】" + 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 USAGE = [
"用法:ticket.mjs <子命令> --repo=owner/name [...](取值型选项 --x=v 与 --x v 两种写法等价)",
" selftest 离线自检(不联网、不需要令牌)",
" labels | milestones 读标签 / 里程碑",
" list [--state=open|closed|all] 列工单",
" verify <n...> 七项断言(有效性:标题/首行/闸门/自包含/引用块/标签)",
" assess <n> | assess --body-file=<文件> 结构评估(§8.0 落点表底稿:五段现状/缺段/空壳/评论清单)",
" create --title=… --body-file=… --label=<id>[,<id>] [--dry-run] [--force]",
" update <n> --body-file=… [--title=…] [--dry-run] [--force]",
" comments <n> [--dump=<文件>] 评论清单 / 删前全量备份",
" comment-add <n> --body-file=… [--dry-run]",
" comment-del <id> [--yes] 默认只预览",
" rename <n> --domain=<词表值> [--gate=…] [--dry-run]",
"(公共:--token-file=<文件> | GITEA_TOKEN · --host=<默认 code.hoteas.com>)"
].join(NL);
const A = parseArgs(process.argv.slice(2));
const cmd = A.pos[0];
if (cmd === "selftest") process.exit(cmdSelftest());
if (!cmd || cmd === "-h" || cmd === "--help") { console.log(USAGE); process.exit(0); }
if (!A.opt.repo && !(cmd === "assess" && A.opt["body-file"])) { console.log("缺 --repo=owner/name" + NL + USAGE); 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 === "assess") code = await cmdAssess(api, A.opt, A.pos[1]);
else if (cmd === "create") code = await cmdCreate(api, A.opt);
else if (cmd === "update") code = await cmdUpdate(api, A.opt, A.pos[1]);
else if (cmd === "comments") code = await cmdComments(api, A.opt, A.pos[1]);
else if (cmd === "comment-add") code = await cmdCommentAdd(api, A.opt, A.pos[1]);
else if (cmd === "comment-del") code = await cmdCommentDel(api, A.opt, A.pos[1]);
else if (cmd === "rename") code = await cmdRename(api, A.opt, A.pos[1]);
else { console.log("未知子命令:" + cmd + NL + USAGE); code = 1; }
process.exit(code);