Files
agents-skills/ticket-management/bin/ticket.mjs
T

158 lines
8.8 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];
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";
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]
];
}
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 --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 || cmd === "-h" || cmd === "--help") { console.log("用法:ticket.mjs <labels|milestones|list|verify|create|rename> --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);