feat(插件): 发布意图提单 V2

This commit is contained in:
cc
2026-08-27 12:03:08 +08:00
parent 72e5396e6d
commit 758aa59470
10 changed files with 1107 additions and 215 deletions
@@ -1,108 +1,203 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
const allowedKeys = new Set([
'type',
'title',
'background',
'acceptance_criteria',
'priority',
'environment',
'reproduction_steps',
'actual_result',
'expected_result',
'impact_scope',
'desired_at',
'references',
'attachments',
"type",
"title",
"background",
"problem_or_goal",
"acceptance_criteria",
"priority",
"environment",
"reproduction_steps",
"actual_result",
"expected_result",
"expected_outcome",
"impact_scope",
"constraints",
"non_goals",
"desired_at",
"references",
"attachments",
]);
export async function readStdin() {
let input = '';
process.stdin.setEncoding('utf8');
let input = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) input += chunk;
if (!input.trim()) throw new Error('请通过 stdin 传入 JSON');
if (!input.trim()) throw new Error("请通过 stdin 传入 JSON");
return JSON.parse(input);
}
/** Normalize the public V2 draft contract without asking for technical design. */
export function normalize(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('输入必须是 JSON 对象');
if (!input || typeof input !== "object" || Array.isArray(input))
throw new Error("输入必须是 JSON 对象");
const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key));
if (unknown.length) throw new Error(`存在禁止或未知字段: ${unknown.join(', ')}`);
if (unknown.length)
throw new Error(`存在禁止或未知字段: ${unknown.join(", ")}`);
const background = input.problem_or_goal ?? input.background;
const expectedResult = input.expected_outcome ?? input.expected_result;
const value = {
type: requiredEnum(input.type, '类型', ['需求', 'Bug']),
title: requiredText(input.title, '标题'),
background: requiredText(input.background, '背景与目标'),
acceptance_criteria: requiredList(input.acceptance_criteria, '验收标准'),
priority: requiredEnum(input.priority, '优先级', ['P0', 'P1', 'P2', 'P3']),
impact_scope: optionalEnumList(input.impact_scope, '影响范围', ['前端', '后端', '全栈', '部署']),
attachments: optionalList(input.attachments, '附件'),
type: requiredEnum(input.type, "类型", ["需求", "Bug"]),
title: requiredText(input.title, "标题"),
background: requiredText(background, "背景与目标"),
expected_result: requiredText(expectedResult, "期望结果"),
priority:
input.priority === undefined
? "P2"
: requiredEnum(input.priority, "优先级", ["P0", "P1", "P2", "P3"]),
acceptance_criteria: optionalList(input.acceptance_criteria, "验收提示"),
impact_scope: optionalScopeList(input.impact_scope),
constraints: optionalList(input.constraints, "约束"),
non_goals: optionalList(input.non_goals, "非目标"),
attachments: attachmentList(input.attachments),
};
for (const [key, label] of [
['environment', '发生环境'],
['actual_result', '实际结果'],
['expected_result', '期望结果'],
['desired_at', '期望完成时间'],
['references', '参考资料'],
["environment", "发生环境"],
["actual_result", "实际结果"],
["references", "参考资料"],
]) {
if (input[key] !== undefined) value[key] = requiredText(input[key], label);
}
if (input.desired_at !== undefined)
value.desired_at = normalizeDesiredAt(input.desired_at);
if (input.reproduction_steps !== undefined)
value.reproduction_steps = requiredList(input.reproduction_steps, '复现步骤');
if (value.type === 'Bug') {
for (const [key, label] of [
['environment', '发生环境'],
['actual_result', '实际结果'],
['expected_result', '期望结果'],
])
if (!value[key]) throw new Error(`Bug 缺少${label}`);
if (!value.reproduction_steps?.length) throw new Error('Bug 缺少复现步骤');
}
value.reproduction_steps = optionalList(input.reproduction_steps, "复现步骤");
return value;
}
export function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (value && typeof value === 'object')
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object")
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
.join(',')}}`;
.join(",")}}`;
return JSON.stringify(value);
}
export function tokens(value) {
const digest = createHash('sha256').update(canonical(value)).digest('hex');
return { confirmation_token: digest, submission_key: `intake-${digest.slice(0, 24)}` };
export function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
export function preview(value) {
export function createDraftIdentity(ownerOpenId) {
const draftId = randomUUID();
const secret = randomBytes(32).toString("base64url");
return {
'类型': value.type,
'标题': value.title,
'背景与目标': value.background,
'验收标准': value.acceptance_criteria,
'优先级': value.priority,
...(value.environment ? { '发生环境': value.environment } : {}),
...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps } : {}),
...(value.actual_result ? { '实际结果': value.actual_result } : {}),
...(value.expected_result ? { '期望结果': value.expected_result } : {}),
...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}),
...(value.attachments.length ? { '附件': value.attachments } : {}),
draft_id: draftId,
confirmation_token: `${draftId}.${secret}`,
confirmation_token_hash: sha256(secret),
submission_key: `intake-v2-${sha256(`${draftId}:${ownerOpenId}`).slice(0, 24)}`,
};
}
export function parseConfirmationToken(token) {
if (typeof token !== "string")
throw new Error("确认令牌缺失或已过期,请重新预览并确认");
const separator = token.indexOf(".");
const draftId = token.slice(0, separator);
const secret = token.slice(separator + 1);
if (
separator < 1 ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
draftId,
) ||
!secret
)
throw new Error("确认令牌缺失或已过期,请重新预览并确认");
return { draftId, secret };
}
export function verifySecret(secret, expectedHash) {
const actual = Buffer.from(sha256(secret), "hex");
const expected = Buffer.from(String(expectedHash ?? ""), "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function contentDigest(value, ownerOpenId, attachments) {
return sha256(canonical({ value, ownerOpenId, attachments }));
}
export function preview(value, attachmentManifest = []) {
return {
类型: value.type,
标题: value.title,
背景与目标: value.background,
期望结果: value.expected_result,
优先级: value.priority,
...(value.acceptance_criteria.length
? { 可选验收提示: value.acceptance_criteria }
: {}),
...(value.environment ? { 发生环境: value.environment } : {}),
...(value.reproduction_steps?.length
? { 复现步骤: value.reproduction_steps }
: {}),
...(value.actual_result ? { 实际结果: value.actual_result } : {}),
...(value.impact_scope.length ? { 范围提示: value.impact_scope } : {}),
...(value.constraints.length ? { 约束: value.constraints } : {}),
...(value.non_goals.length ? { 非目标: value.non_goals } : {}),
...(value.desired_at ? { 期望完成时间: value.desired_at } : {}),
...(value.references ? { 参考资料: value.references } : {}),
...(attachmentManifest.length
? {
附件: attachmentManifest.map(({ path, size, sha256: digest }) => ({
path,
size,
sha256: digest,
})),
}
: {}),
};
}
export async function attachmentManifest(paths, cwd = process.cwd()) {
const root = await realpath(cwd);
const manifest = [];
for (const path of paths) {
const target = await realpath(resolve(root, path));
const relativePath = relative(root, target);
if (relativePath.startsWith("..") || isAbsolute(relativePath))
throw new Error(`附件超出当前目录: ${path}`);
const metadata = await stat(target);
if (!metadata.isFile()) throw new Error(`附件不是普通文件: ${path}`);
manifest.push({
path,
size: metadata.size,
sha256: createHash("sha256").update(await readFile(target)).digest("hex"),
});
}
return manifest;
}
export async function assertSafeAttachmentPaths(paths, cwd = process.cwd()) {
await attachmentManifest(paths, cwd);
}
export async function readJson(path, fallback) {
try {
return JSON.parse(await readFile(path, 'utf8'));
return JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error?.code === 'ENOENT') return fallback;
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
export async function writeJsonAtomic(path, value) {
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
mode: 0o600,
});
await rename(temporary, path);
}
function requiredText(value, label) {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label}缺失`);
if (typeof value !== "string" || !value.trim())
throw new Error(`${label}缺失`);
return value.trim();
}
function requiredList(value, label) {
@@ -114,14 +209,50 @@ function optionalList(value, label) {
? []
: requiredList(value, label);
}
function attachmentList(value) {
const list = optionalList(value, "附件");
for (const path of list)
if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
throw new Error(`不安全的附件路径: ${path}`);
return list;
}
function requiredEnum(value, label, options) {
if (!options.includes(value)) throw new Error(`${label}必须是: ${options.join(' / ')}`);
if (!options.includes(value))
throw new Error(`${label}必须是: ${options.join(" / ")}`);
return value;
}
function optionalEnumList(value, label, options) {
const list = value === undefined || (Array.isArray(value) && value.length === 0)
? []
: requiredList(value, label);
const list = optionalList(value, label);
for (const item of list) requiredEnum(item, label, options);
return [...new Set(list)];
}
function optionalScopeList(value) {
const list = optionalEnumList(value, "范围提示", [
"前端",
"后端",
"全栈",
"部署",
]);
if (list.includes("全栈") && (list.includes("前端") || list.includes("后端")))
throw new Error("范围提示选择全栈后不能再同时选择前端或后端");
return list;
}
function normalizeDesiredAt(value) {
const text = requiredText(value, "期望完成时间");
const match =
/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(text);
if (!match)
throw new Error("期望完成时间必须是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss");
const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
const [y, m, d, h, min, s] = [year, month, day, hour, minute, second].map(Number);
const validDate =
m >= 1 &&
m <= 12 &&
d >= 1 &&
d <= new Date(Date.UTC(y, m, 0)).getUTCDate() &&
h <= 23 &&
min <= 59 &&
s <= 59;
if (!validDate) throw new Error("期望完成时间不是有效日期时间");
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}