fix(ticket-management): 参数两种写法都收 + 新增 selftest + 模板域分隔符改连字符

- bin/ticket.mjs: parseArgs 同时接受 --x=v 与 --x v(仅取值型选项,布尔开关不吃后一个参数);
  新增 selftest 子命令(不联网、不需要令牌),覆盖解析器 3 条与断言正反 5 条
- SKILL.md: §5 命令块补 selftest 与「两种写法等价」;子命令表补 selftest 行;
  §6 留档 403/405 假故障的成因(示例写空格、实现只认等号);frontmatter description 补 selftest
- templates/issue.md: 域层级分隔符 / → -(断言 3 按 - 取 L1,斜杠版照抄必挂)

验证:node bin/ticket.mjs selftest → 8/8,退出码 0
This commit is contained in:
2026-09-24 10:16:49 +08:00
parent 47226f78be
commit bae73025f1
4 changed files with 71 additions and 14 deletions
+36 -5
View File
@@ -10,11 +10,18 @@ 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];
// 取值型选项:`--repo=o/r` 与 `--repo o/r` 两种写法都收(SKILL.md 的示例用的是后者)
const VALUE_OPTS = ["repo", "title", "body-file", "label", "milestone", "token-file", "host", "state", "limit", "domain", "gate"];
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);
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 };
}
@@ -62,6 +69,29 @@ function checksOf(title, body) {
["6 有引用块 ⇒ 有 ref-owner", body.indexOf("<!--") < 0 ? true : body.indexOf("ref-owner:") >= 0]
];
}
// 自检(不联网、不需要令牌):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 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);
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; });
@@ -102,7 +132,7 @@ async function cmdVerify(api, opt, nums) {
}
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; }
if (!title) { console.log("用法:create --repo=o/r(或 --repo o/r)--title=... --body-file=... [--label=<id>] [--dry-run] [--force]"); 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);
@@ -144,7 +174,8 @@ async function cmdRename(api, opt, n) {
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 (cmd === "selftest") process.exit(cmdSelftest());
if (!cmd || cmd === "-h" || cmd === "--help") { console.log("用法:ticket.mjs <labels|milestones|list|verify|create|rename|selftest> [--repo=owner/name | --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;