158 lines
8.8 KiB
JavaScript
158 lines
8.8 KiB
JavaScript
#!/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);
|