Files
agents-skills/ticket-management/bin/ticket.mjs
T
hdba4 bae73025f1 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
2026-09-24 10:16:49 +08:00

189 lines
11 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...> | 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/", "分析/数据/", "分析/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 (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";
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(); }
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 共用)
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]
];
}
// 自检(不联网、不需要令牌):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; });
}
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(或 --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);
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 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 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("用法: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;
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);