diff --git a/AGENTS.md b/AGENTS.md index 069be90..c292e38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,11 @@ UI 基线规则(2026-08-15 更新): - `验证清单` - `最终结论` +## 开发进度同步 + +- 可说明的工作提交后,运行 `npm run progress:sync -- ... --apply`,按真实证据使用 `code_complete`、`testing` 或 `done`。 +- 仅同步 Git 元数据;目录匹配不唯一就停止,同步失败不得阻断 Git 与生产发布。 + ## 7. 建议先读的文档 - `README.md` diff --git a/package.json b/package.json index 25da7d4..eba5795 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "storage:legacy-boundary": "node scripts/check-legacy-storage-boundaries.mjs", "storage:legacy-boundary:test": "node --test scripts/check-legacy-storage-boundaries.test.mjs", "migration:safe:test": "node --test scripts/safe-production-migrate.test.mjs", + "progress:sync": "node scripts/kaifa-progress-sync.mjs", "performance-survey:test-data:audit": "tsx scripts/performance-survey-test-data.ts --mode=audit", "performance-survey:test-data:clear": "tsx scripts/performance-survey-test-data.ts --mode=clear", "performance-survey:position-config:seed": "tsx scripts/seed-performance-survey-position-config.ts", diff --git a/scripts/kaifa-progress-sync.mjs b/scripts/kaifa-progress-sync.mjs new file mode 100644 index 0000000..2bdbde7 --- /dev/null +++ b/scripts/kaifa-progress-sync.mjs @@ -0,0 +1,151 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const ALLOWED_STATUSES = new Set(['code_complete', 'testing', 'done']) +const CONFIG_PATH = resolve('.private/kaifa-progress-sync/.env.local') + +export function parseArgs(argv) { + const args = new Map() + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (!token.startsWith('--')) continue + if (token === '--apply' || token === '--resolve' || token === '--help') args.set(token.slice(2), true) + else args.set(token.slice(2), argv[++index]) + } + return args +} + +export function validateOptions(options) { + for (const name of ['module', 'feature', 'status', 'summary']) { + if (!options.get(name)) throw new Error(`缺少 --${name}`) + } + if (!ALLOWED_STATUSES.has(options.get('status'))) throw new Error('status 只能是 code_complete、testing 或 done') + if (options.get('status') === 'done' && options.get('test-status') !== 'passed') throw new Error('done 必须提供 --test-status passed') + if (options.get('status') === 'done' && !options.get('completion-evidence')) throw new Error('done 必须提供 --completion-evidence') +} + +export function buildIdempotencyKey(sha, status) { + return `gongxiangp2-${sha}-${status}` +} + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }) +} + +async function readConfig() { + const text = await readFile(CONFIG_PATH, 'utf8') + const config = Object.fromEntries(text.split(/\r?\n/).map((line) => line.match(/^([^#=]+)=(.*)$/)).filter(Boolean).map((match) => [match[1], match[2]])) + for (const name of ['API_BASE', 'API_KEY', 'DEVELOPER', 'DEPARTMENT', 'PROJECT', 'PROJECT_ID']) { + if (!config[name]) throw new Error(`本地配置缺少 ${name}`) + } + return config +} + +async function api(config, path, init = {}) { + const response = await fetch(`${config.API_BASE}${path}`, { + ...init, + headers: { Authorization: `Bearer ${config.API_KEY}`, ...(init.headers || {}) }, + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(`API ${response.status}: ${body.error?.message || body.error || body.message || '请求失败'}`) + return body +} + +function collectProjectMatches(value, projectId, matches = []) { + if (!value || typeof value !== 'object') return matches + if (String(value.projectId || value.id || '') === projectId) matches.push(value) + for (const child of Array.isArray(value) ? value : Object.values(value)) collectProjectMatches(child, projectId, matches) + return matches +} + +function findFeature(project, moduleName, featureName) { + const module = (project.modules || []).find((item) => (item.moduleName || item.name) === moduleName) + const feature = (module?.features || []).find((item) => (item.featureName || item.name) === featureName) + return module && feature ? { + moduleId: module.moduleId || module.id, + featureId: feature.featureId || feature.id, + } : null +} + +async function resolveTarget(config, options) { + const catalog = await api(config, '/my/catalog') + let project = collectProjectMatches(catalog, config.PROJECT_ID)[0] + if (!project) throw new Error('目录中找不到当前项目') + let target = findFeature(project, options.get('module'), options.get('feature')) + if (target) return target + if (!options.get('resolve')) throw new Error('模块或子功能不存在;确认精确名称后使用 --resolve') + const result = await api(config, `/projects/${config.PROJECT_ID}/features/resolve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + context: { department: config.DEPARTMENT, project: config.PROJECT, developer: config.DEVELOPER }, + items: [{ module_name: options.get('module'), feature_name: options.get('feature'), create_module: true }], + }), + }) + const item = result.items?.[0] + if (!item?.canPush) throw new Error('新目录未返回可推送权限') + return { moduleId: item.moduleId, featureId: item.featureId } +} + +function readCommit(revision, target, config) { + if (!/^(?:HEAD|[0-9a-f]{7,40})$/i.test(revision)) throw new Error('sha 只能是 HEAD 或 7-40 位十六进制提交号') + const [sha, message, authorName, authorEmail, committedAt] = git('show', '-s', '--format=%H%x00%s%x00%aN%x00%aE%x00%cI', revision).trim().split('\0') + if (authorEmail.toLowerCase() !== config.DEVELOPER.toLowerCase()) throw new Error(`提交作者 ${authorEmail} 不属于当前同步开发者`) + const numstat = new Map() + for (const line of git('diff-tree', '--root', '--no-commit-id', '--numstat', '-r', '--no-renames', sha).trim().split(/\r?\n/).filter(Boolean)) { + const [added, deleted, ...pathParts] = line.split('\t') + numstat.set(pathParts.join('\t'), { additions: added === '-' ? 0 : Number(added), deletions: deleted === '-' ? 0 : Number(deleted) }) + } + const changedFiles = git('diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '--no-renames', sha).trim().split(/\r?\n/).filter(Boolean).map((line) => { + const [status, ...pathParts] = line.split('\t') + const path = pathParts.join('\t') + return { path, status: status[0], ...(numstat.get(path) || { additions: 0, deletions: 0 }) } + }) + const stats = changedFiles.reduce((total, file) => ({ additions: total.additions + file.additions, deletions: total.deletions + file.deletions, files_changed: total.files_changed + 1 }), { additions: 0, deletions: 0, files_changed: 0 }) + return { sha, message, author_name: authorName, author_email: authorEmail, committed_at: committedAt, branch: git('branch', '--show-current').trim(), module_id: target.moduleId, feature_id: target.featureId, changed_files: changedFiles, stats } +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv) + if (options.get('help')) { + console.log('用法: npm run progress:sync -- --module <模块> --feature <子功能> --status --summary <摘要> [--test-status passed] [--completion-evidence <证据>] [--resolve] [--apply]') + return + } + validateOptions(options) + const config = await readConfig() + const target = await resolveTarget(config, options) + const commit = readCommit(options.get('sha') || 'HEAD', target, config) + const update = { + module_id: target.moduleId, + feature_id: target.featureId, + change_type: options.get('change-type') || 'technical', + status: options.get('status'), + summary: options.get('summary'), + blockers: options.get('blockers') || null, + evidence_shas: [commit.sha], + ...(options.get('technical-summary') ? { technical_summary: options.get('technical-summary') } : {}), + ...(options.get('test-status') ? { test_status: options.get('test-status') } : {}), + ...(options.get('completion-evidence') ? { completion_evidence: options.get('completion-evidence') } : {}), + ...(options.get('next-steps') ? { next_steps: options.get('next-steps') } : {}), + } + const payload = { context: { department: config.DEPARTMENT, project: config.PROJECT, developer: config.DEVELOPER }, commits: [commit], updates: [update] } + if (!options.get('apply')) { + console.log(JSON.stringify({ dryRun: true, sha: commit.sha, author: `${commit.author_name} <${commit.author_email}>`, module: options.get('module'), feature: options.get('feature'), status: options.get('status'), stats: commit.stats }, null, 2)) + return + } + const result = await api(config, `/projects/${config.PROJECT_ID}/progress-sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': buildIdempotencyKey(commit.sha, options.get('status')) }, + body: JSON.stringify(payload), + }) + const receiptPath = resolve('.private/kaifa-progress-sync/latest-response.json') + await mkdir(dirname(receiptPath), { recursive: true }) + await writeFile(receiptPath, `${JSON.stringify(result, null, 2)}\n`) + console.log(JSON.stringify({ ok: true, sha: commit.sha, status: options.get('status'), module: options.get('module'), feature: options.get('feature') }, null, 2)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1 }) +} diff --git a/scripts/kaifa-progress-sync.test.mjs b/scripts/kaifa-progress-sync.test.mjs new file mode 100644 index 0000000..540d175 --- /dev/null +++ b/scripts/kaifa-progress-sync.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { buildIdempotencyKey, parseArgs, validateOptions } from './kaifa-progress-sync.mjs' + +test('解析同步参数并生成稳定幂等键', () => { + const options = parseArgs(['--module', '权限与私密素材', '--feature', '权限组保存', '--status', 'testing', '--summary', '完成验证', '--apply']) + assert.equal(options.get('module'), '权限与私密素材') + assert.equal(options.get('apply'), true) + assert.equal(buildIdempotencyKey('abc123', 'testing'), 'gongxiangp2-abc123-testing') +}) + +test('done 必须有通过测试和完成证据', () => { + const base = ['--module', '模块', '--feature', '功能', '--status', 'done', '--summary', '完成'] + assert.throws(() => validateOptions(parseArgs(base)), /test-status passed/) + assert.throws(() => validateOptions(parseArgs([...base, '--test-status', 'passed'])), /completion-evidence/) + assert.doesNotThrow(() => validateOptions(parseArgs([...base, '--test-status', 'passed', '--completion-evidence', '已上线'])) ) +}) + +test('help 是独立布尔参数', () => { + assert.equal(parseArgs(['--help']).get('help'), true) +})