feat(插件): 新增产品需求提交 Skill

This commit is contained in:
cc
2026-08-11 11:42:58 +08:00
commit dcbbe886fa
10 changed files with 387 additions and 0 deletions
@@ -0,0 +1,125 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
const allowedKeys = new Set([
'type',
'title',
'background',
'acceptance_criteria',
'priority',
'environment',
'reproduction_steps',
'actual_result',
'expected_result',
'impact_scope',
'desired_at',
'references',
'attachments',
]);
export async function readStdin() {
let input = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) input += chunk;
if (!input.trim()) throw new Error('请通过 stdin 传入 JSON');
return JSON.parse(input);
}
export function normalize(input) {
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(', ')}`);
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, '附件'),
};
for (const [key, label] of [
['environment', '发生环境'],
['actual_result', '实际结果'],
['expected_result', '期望结果'],
['desired_at', '期望完成时间'],
['references', '参考资料'],
]) {
if (input[key] !== undefined) value[key] = requiredText(input[key], label);
}
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 缺少复现步骤');
}
return value;
}
export function canonical(value) {
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(',')}}`;
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 preview(value) {
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 } : {}),
};
}
export async function readJson(path, fallback) {
try {
return JSON.parse(await readFile(path, 'utf8'));
} catch (error) {
if (error?.code === 'ENOENT') return fallback;
throw error;
}
}
function requiredText(value, label) {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label}缺失`);
return value.trim();
}
function requiredList(value, label) {
if (!Array.isArray(value) || !value.length) throw new Error(`${label}缺失`);
return value.map((item) => requiredText(item, label));
}
function optionalList(value, label) {
return value === undefined || (Array.isArray(value) && value.length === 0)
? []
: requiredList(value, label);
}
function requiredEnum(value, label, options) {
if (!options.includes(value)) throw new Error(`${label}必须是: ${options.join(' / ')}`);
return value;
}
function optionalEnumList(value, label, options) {
const list = value === undefined ? [] : requiredList(value, label);
for (const item of list) requiredEnum(item, label, options);
return [...new Set(list)];
}
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import { normalize, preview, readStdin, tokens } from './intake-lib.mjs';
try {
const value = normalize(await readStdin());
console.log(JSON.stringify({ ok: true, preview: preview(value), ...tokens(value) }, null, 2));
} catch (error) {
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' }));
process.exitCode = 1;
}
@@ -0,0 +1,113 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { mkdir, realpath, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, isAbsolute, resolve } from 'node:path';
import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs';
const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc';
const tableId = 'tblelMMlOif1YHPD';
const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json');
try {
const value = normalize(await readStdin());
const identity = tokens(value);
const confirmation = argument('--confirm');
if (!confirmation || confirmation !== identity.confirmation_token)
throw new Error('确认令牌缺失或已过期,请重新预览并确认');
const receipts = await readJson(receiptPath, {});
if (receipts[identity.submission_key]) {
console.log(JSON.stringify({ ok: true, duplicate: true, ...receipts[identity.submission_key] }, null, 2));
process.exit(0);
}
const auth = lark(['auth', 'status', '--json', '--verify']);
const user = auth.identities?.user;
if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权');
const fieldList = lark(['base', '+field-list', '--base-token', baseToken, '--table-id', tableId, '--as', 'user']);
const fields = new Map((fieldList.fields ?? []).map((field) => [field.name, field]));
for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源'])
if (!fields.has(name)) throw new Error(`Base 缺少必需字段: ${name}`);
const payload = {
'类型': value.type,
'标题': value.title,
'需求背景与目标': value.background,
'验收标准': value.acceptance_criteria.map((item, index) => `${index + 1}. ${item}`).join('\n'),
'优先级': value.priority,
'产品负责人': [{ id: user.openId }],
'提交幂等键': identity.submission_key,
'提交来源': 'Codex Skill',
...(value.environment ? { '发生环境': value.environment } : {}),
...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps.map((item, index) => `${index + 1}. ${item}`).join('\n') } : {}),
...(value.actual_result ? { '实际结果': value.actual_result } : {}),
...(value.expected_result ? { '期望结果': value.expected_result } : {}),
...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}),
...(value.desired_at ? { '期望完成时间': value.desired_at } : {}),
...(value.references ? { '参考资料': value.references } : {}),
};
const created = lark([
'base', '+record-upsert', '--base-token', baseToken, '--table-id', tableId,
'--json', JSON.stringify(payload), '--as', 'user',
]);
const recordId = findId(created, 'rec');
if (!recordId) throw new Error('飞书未返回 record_id');
const recordUrl = findUrl(created) ?? `https://feishu.cn/base/${baseToken}?table=${tableId}&record=${recordId}`;
const receipt = {
record_id: recordId,
record_url: recordUrl,
submission_key: identity.submission_key,
status: '已登记,等待系统分配任务编号',
};
receipts[identity.submission_key] = receipt;
await mkdir(dirname(receiptPath), { recursive: true, mode: 0o700 });
await writeFile(receiptPath, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 });
const attachmentField = fields.get('附件');
for (const attachment of value.attachments) {
if (!attachmentField?.id) throw new Error('Base 缺少附件字段');
await assertSafePath(attachment);
lark([
'base', '+record-upload-attachment', '--base-token', baseToken, '--table-id', tableId,
'--record-id', recordId, '--field-id', attachmentField.id, '--file', attachment, '--as', 'user',
]);
}
console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2));
} catch (error) {
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' }));
process.exitCode = 1;
}
function lark(args) {
const output = execFileSync('lark-cli', args, {
encoding: 'utf8',
env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1', LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const envelope = JSON.parse(output);
if (envelope.ok === false) throw new Error([envelope.error?.message, envelope.error?.hint].filter(Boolean).join(': '));
return envelope.data ?? envelope;
}
function argument(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function assertSafePath(path) {
if (typeof path !== 'string' || !path || isAbsolute(path) || path.split('/').includes('..'))
throw new Error(`不安全的附件路径: ${path}`);
const root = await realpath(process.cwd());
const target = await realpath(resolve(root, path));
if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`);
}
function findId(value, prefix) {
if (typeof value === 'string' && value.startsWith(prefix)) return value;
if (Array.isArray(value)) for (const item of value) { const found = findId(item, prefix); if (found) return found; }
if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findId(item, prefix); if (found) return found; }
}
function findUrl(value) {
if (typeof value === 'string' && /^https?:\/\//.test(value)) return value;
if (Array.isArray(value)) for (const item of value) { const found = findUrl(item); if (found) return found; }
if (value && typeof value === 'object') for (const item of Object.values(value)) { const found = findUrl(item); if (found) return found; }
}