用户 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 · 空壳 无。
437 lines
29 KiB
JavaScript
437 lines
29 KiB
JavaScript
#!/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);
|