Files
agents-skills/ticket-management/bin/ticket.mjs
T
xuxiang 79a9c13c91 merge: 结构评估先行(本席 47f82df)+ 参数两写法 / selftest / DSH 接法(bae7302)
两条并行改动落在同一批文件上 ⇒ 逐个文件按「谁更对」合入,未整边取用:

【取 bae7302】
- bin/ticket.mjs parseArgs:取值型选项 `--x=v` 与 `--x v` 两种写法都收(本席旧版只认等号);
  本席新增的 `--dump` 一并纳入 VALUE_OPTS。
- bin/ticket.mjs `selftest`(不联网、不需要令牌)—— 本席在其上补 4 条 §8 结构判定用例(共 13 条)。
- README「接到 DSH」整节:技能发现只有一层、逐技能 junction、rank 表、用户级接法
  (本席原稿「整仓当作技能根」是错的,已弃)。
- SKILL §6「参数写法的坑(已修 · 留档)」;templates 的「域内层级用 - 而非 /」提示行。

【取本席】
- SKILL:闸门 A 先评结构(§8.0)+ 闸门 B 只记要点(§8.2)+ 四段任务全貌(§8.1)
  + 结构有效性与整洁性自检(§8.3);修 §1 规则 2 与 §2 的双括号/单括号自相矛盾。
- bin/ticket.mjs:assess / update / comments(含 --dump 删前备份)/ comment-add / comment-del。
- templates:正文改四段骨架。README:技能清单行。

【同步废止的旧口径】
- SKILL §9「`--repo` 必须用等号形式」⇒ 改为「两种写法都可 · 旧口径已废止」,并指向 §6 成因留档。

【验证 · E1 · 本机】
- `node --check` 通过;`selftest` 13/13;`verify 157 158 159` = 21/21;
- 两种参数写法各跑一次 `verify` 均 7/7;`assess` 对三单正文的段位 / 顺序 / 空壳判定与实况相符。
2026-09-24 10:26:30 +08:00

411 lines
26 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: "按事实" }
];
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 isPostNote(text) { return /更正|订正|勘误|补充|追加/.test(text); }
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), post: isPostNote(h.text) }; });
const main = tops.filter(function (t) { return !t.post && t.idx >= 0; });
const order = main.map(function (t) { return t.idx; });
const ordered = order.every(function (v, i) { return i === 0 || order[i - 1] <= v; });
const hits = tops.filter(function (t) { return t.idx >= 0; });
const postNotes = tops.filter(function (t) { return t.post; }).map(function (t) { return t.text; });
const unclassified = tops.filter(function (t) { return t.idx < 0 && !t.post && !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, postNotes: postNotes, 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 === "必备" ? " ← 未识别到(必备段:确认是否用了同义小标题,或确实缺)" : " ← 未识别到(未发生 / 未测就不写,不要空壳)");
console.log(" " + (own.length ? "[✓]" : "[ ]") + " " + SECTIONS[i].key + "(" + SECTIONS[i].need + ")" + tail);
}
console.log(" 顺序:" + (st.ordered ? "OK" : "⚠ 主叙事未按 背景→提案→结果→效果 排列 ⇒ 调整(更正 / 补充小节不计)"));
if (st.postNotes.length) console.log(" 后置小节(不计顺序):" + st.postNotes.map(function (t) { return "## " + t; }).join(" · "));
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(" · ") : "无"));
}
// 自检(不联网、不需要令牌):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);
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 + " 字 首行: " + (b.split(NL)[0] || "").slice(0, 90));
}
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 + " 字 首行: " + (b.split(NL)[0] || "").slice(0, 90));
}
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);