#!/usr/bin/env node // ticket.mjs — Gitea 工单工具(人读 + 机读规范 · **结构评估先行**) // 子命令:labels | milestones | list | verify | assess | create // update | comments | comment-add | comment-del | rename // 令牌: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("/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 + "