169 lines
9.2 KiB
JavaScript
169 lines
9.2 KiB
JavaScript
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')
|
||
}
|
||
|
||
// 平台要求每次推送携带与线上目录逐项一致的完整功能清单(inventory_check)。
|
||
// 文件由全量盘点脚本按 progress-sync GET 回读的最终状态生成,禁止手改占位。
|
||
export async function readInventoryCheck(options) {
|
||
const path = options.get('inventory')
|
||
if (!path) return undefined
|
||
const parsed = JSON.parse(await readFile(resolve(path), 'utf8'))
|
||
if (!Array.isArray(parsed) || parsed.length === 0) throw new Error('--inventory 文件必须是包含全部功能的非空数组')
|
||
for (const item of parsed) {
|
||
for (const field of ['module_id', 'module_name', 'feature_id', 'feature_name', 'feature_description', 'status', 'progress_percent']) {
|
||
if (item[field] === undefined) throw new Error(`--inventory 条目缺少字段 ${field}`)
|
||
}
|
||
}
|
||
return parsed
|
||
}
|
||
|
||
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 <code_complete|testing|done> --summary <摘要> [--test-status passed] [--completion-evidence <证据>] [--technical-summary <实现说明>] [--next-steps <下一步>] [--inventory <清单文件>] [--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] }
|
||
const inventoryCheck = await readInventoryCheck(options)
|
||
if (inventoryCheck) payload.inventory_check = inventoryCheck
|
||
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 })
|
||
}
|