#!/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 = ["验收判据", "反证条件", "遗留"]; 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); } 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(" · ") : "无")); } 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 --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=[,] [--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; } // 标签必传(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=[,](标签表: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 --repo=o/r --body-file=<文件> [--title=…] [--dry-run] [--force]"); return 1; } const bodyFile = opt["body-file"]; if (!bodyFile) { console.log("用法:update --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 --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 --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 --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 --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 --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 [...]", " labels | milestones 读标签 / 里程碑", " list [--state=open|closed|all] 列工单", " verify 七项断言(有效性:标题/首行/闸门/自包含/引用块/标签)", " assess | assess --body-file=<文件> 结构评估(§8.0 落点表底稿:四段现状/缺段/空壳/评论清单)", " create --title=… --body-file=… --label=[,] [--dry-run] [--force]", " update --body-file=… [--title=…] [--dry-run] [--force]", " comments [--dump=<文件>] 评论清单 / 删前全量备份", " comment-add --body-file=… [--dry-run]", " comment-del [--yes] 默认只预览", " rename --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 || 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);