feat(插件): 发布意图提单 V2
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user