gongxiangp2/scripts/kaifa-progress-sync.mjs

169 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 })
}