2026-09-22 15:48:52 +08:00
|
|
|
|
#!/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);
|
2026-09-22 16:03:27 +08:00
|
|
|
|
const BAD = ["codes/", "分析/数据/", "分析/D-", "分析/E-", "分析/A-", "分析/B-", "分析/C-", "分析/F-", "分析/_tmp", "分析/_archive", "分析/README", "86 §", "80 · V", "/home/", "C:" + BACKSLASH];
|
2026-09-22 15:48:52 +08:00
|
|
|
|
|
|
|
|
|
|
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(); }
|
|
|
|
|
|
return { brackets: brackets, 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.brackets.length > 0 && DOMAINS.indexOf(sp.brackets[0]) >= 0],
|
|
|
|
|
|
["2 首行含 域=", L.indexOf("域=") >= 0],
|
|
|
|
|
|
["3 标题主域 = 首行 域 的 L1", sp.brackets.length > 0 && dom.split("/")[0] === sp.brackets[0]],
|
|
|
|
|
|
["4 第二段 = 闸门=", sp.brackets.length < 2 ? true : (GATES.indexOf(sp.brackets[1]) >= 0 && sp.brackets[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.brackets.length || DOMAINS.indexOf(sp.brackets[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 title = "【" + dom + "】";
|
|
|
|
|
|
if (opt.gate && opt.gate !== "可开工") {
|
|
|
|
|
|
if (GATES.indexOf(opt.gate) < 0) { console.log("闸门不在词表:" + opt.gate); return 1; }
|
|
|
|
|
|
title += "【" + opt.gate + "】";
|
|
|
|
|
|
}
|
|
|
|
|
|
title += 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);
|