feat: 接入项目推进计划看板
This commit is contained in:
parent
b1bfbe2400
commit
adf98f2105
1
.gitignore
vendored
1
.gitignore
vendored
@ -112,3 +112,4 @@ modules/*
|
||||
/.caiwu-build/
|
||||
.workbuddy/
|
||||
runtime/.env
|
||||
/.private/kaifa-progress-sync/
|
||||
|
||||
1
.mailmap
Normal file
1
.mailmap
Normal file
@ -0,0 +1 @@
|
||||
chenxi <yuechenna@dingtalk.com> chenxi <chenxi@caiwuxitong.local>
|
||||
@ -77,3 +77,5 @@
|
||||
|
||||
- 本文件是团队当前有效的简明规则。`DESIGN.md` 和 `docs/开发与部署SOP.md` 只在对应任务中按需读取。
|
||||
- 详细文档与本文件冲突时,以本文件和负责人最新明确要求为准;历史截图、旧聊天记录和临时排错方案不能覆盖当前规则。
|
||||
- 可说明的工作提交后运行增量的项目推进同步。
|
||||
- 仅同步 Git 元数据;匹配不清或同步失败时停止同步,不影响提交、推送或发布。
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"version": "3.1.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"progress:sync": "node scripts/kaifa-progress-sync.mjs",
|
||||
"dev": "npm run development",
|
||||
"development": "mix",
|
||||
"watch": "mix watch",
|
||||
|
||||
412
scripts/kaifa-progress-sync.mjs
Normal file
412
scripts/kaifa-progress-sync.mjs
Normal file
@ -0,0 +1,412 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const ALLOWED_STATUSES = new Set(['code_complete', 'testing', 'done'])
|
||||
const DEFAULT_API_BASE = 'https://kaifa.yydd.ai/api/v1'
|
||||
const DEFAULT_SINCE = '30 days ago'
|
||||
|
||||
export const FEATURE_RULES = [
|
||||
{
|
||||
key: 'team-system-database', priority: 90, moduleName: '数据接入与共享镜像', featureName: '团队系统数据库与新人接入',
|
||||
subject: /系统数据库|团队数据库|共享连接|新人|引导库/i,
|
||||
paths: /(?:SystemDatabase|TeamSystemDatabase|ConnectTeamSystemDatabase|local-init\.sh)/i,
|
||||
},
|
||||
{
|
||||
key: 'upstream-connections', priority: 90, moduleName: '数据接入与共享镜像', featureName: '多上游数据库连接与字段目录',
|
||||
subject: /operations_replica|经营分析副本库|PostgreSQL|云数据库|多数据库|上游.*(?:连接|驱动|隧道|ANSI|目录)/i,
|
||||
paths: /(?:Upstream(?:Connection|DatabaseProbe|OdbcConnection|ReadOnlyQuery|SchemaCatalog)|SystemSettings\/Databases|system_settings\/databases)/i,
|
||||
},
|
||||
{
|
||||
key: 'business-mirror-sync', priority: 70, moduleName: '数据接入与共享镜像', featureName: '业务镜像全量与增量同步',
|
||||
subject: /业务镜像|共享镜像|共享快照|快照|增量同步|business mirror|shared business data|\bmirrored?\b/i,
|
||||
paths: /(?:BusinessMirror|SnapshotRefresher|UpstreamQueryCache|upstream_mirrors\.php)/i,
|
||||
},
|
||||
{
|
||||
key: 'business-directory', priority: 60, moduleName: '基础资料与经营报表', featureName: '公司、店铺与商标基础资料',
|
||||
subject: /公司店铺商标|基础资料|业务管理页面|中文表头|经营信息|business company|store lifecycle/i,
|
||||
paths: /(?:BusinessInformationMirrorRepository|store-management\/store-information|company-management\/company-information|trademark-management\/trademark-information)/i,
|
||||
},
|
||||
{
|
||||
key: 'store-reports', priority: 80, moduleName: '基础资料与经营报表', featureName: '店铺与主体维度经营报表',
|
||||
subject: /店铺维度|主体维度|经营报表|store report|finance prototype business reports/i,
|
||||
paths: /(?:StoreReport|store-management\/store-report|company-management\/entity-report)/i,
|
||||
},
|
||||
{
|
||||
key: 'installation-details', priority: 100, moduleName: '基础资料与经营报表', featureName: '安装费用与明细查询',
|
||||
subject: /安装明细|installation/i,
|
||||
paths: /InstallationDetail/i,
|
||||
},
|
||||
{
|
||||
key: 'order-expenses', priority: 70, moduleName: '订单、退款与店铺收款', featureName: '订单售后与商品推广费用',
|
||||
subject: /订单|售后|推广费用|order mirror|promotion sku/i,
|
||||
paths: /(?:OrderData|order-data\/after-sales-expenses|order-data\/promotion-expenses)/i,
|
||||
},
|
||||
{
|
||||
key: 'refunds', priority: 100, moduleName: '订单、退款与店铺收款', featureName: '退款数据与导出',
|
||||
subject: /退款|refund/i,
|
||||
paths: /(?:refunds?|non-jingxi-refund)/i,
|
||||
},
|
||||
{
|
||||
key: 'store-receivables', priority: 100, moduleName: '订单、退款与店铺收款', featureName: '店铺收款账户与支付宝账单',
|
||||
subject: /收款账户|支付宝|store receiving/i,
|
||||
paths: /(?:AlipayBills|StoreReceiving|store-receiving)/i,
|
||||
},
|
||||
{
|
||||
key: 'marketing-bills', priority: 60, moduleName: '营销账单与供应商应付', featureName: '淘系、1688、拼多多与京东营销账单',
|
||||
subject: /营销|Alibaba|1688|京东|拼多多|账单.*导出|marketing bills|platform bills/i,
|
||||
paths: /(?:MarketingAccountBill|AlibabaBill|JdWalletBill|PinduoduoSpreadBill|TmallTaobaoSpreadBill|marketing_.*bills)/i,
|
||||
},
|
||||
{
|
||||
key: 'supplier-products', priority: 80, moduleName: '营销账单与供应商应付', featureName: '商品价格、普通商品与供应商赔偿',
|
||||
subject: /商品价格|普通商品|商品及库存|供应商|赔偿|product price/i,
|
||||
paths: /(?:ProductPrice|SupplierPayable|supplier-payables)/i,
|
||||
},
|
||||
{
|
||||
key: 'courier-esign', priority: 90, moduleName: '快递与审批', featureName: '快递报价与易签宝协议签署',
|
||||
subject: /快递|报价|签署|协议|E\s*签|esign|cooperation|courier|signing/i,
|
||||
paths: /(?:Courier|Esign|agreement-signing|quote-management)/i,
|
||||
},
|
||||
{
|
||||
key: 'ding-payables', priority: 100, moduleName: '快递与审批', featureName: '钉钉应付流程与状态同步',
|
||||
subject: /钉钉|应付.*流程|ding payable/i,
|
||||
paths: /(?:Ding|ding_|dingtalk)/i,
|
||||
},
|
||||
{
|
||||
key: 'production-release', priority: 80, moduleName: '生产发布与团队协作', featureName: '生产服务器发布、回滚与存储',
|
||||
subject: /发布|部署|服务器|构建|缓存|七牛|production|生产业务迁移|迁移兼容/i,
|
||||
paths: /(?:deploy|deploy-from-workstation|Dockerfile|Qiniu)/i,
|
||||
},
|
||||
{
|
||||
key: 'team-collaboration', priority: 60, moduleName: '生产发布与团队协作', featureName: '团队规则、任务预检与导航权限',
|
||||
subject: /规则|预检|敏感|协作|目录|导航|权限/i,
|
||||
paths: /(?:AGENTS\.md|codex-task-preflight|business_navigation|ShowInAdmin)/i,
|
||||
},
|
||||
]
|
||||
|
||||
function fail(message) {
|
||||
const error = new Error(message)
|
||||
error.name = 'ProgressSyncError'
|
||||
throw error
|
||||
}
|
||||
|
||||
export function parseEnv(text) {
|
||||
const result = {}
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const splitAt = line.indexOf('=')
|
||||
if (splitAt < 1) continue
|
||||
result[line.slice(0, splitAt).trim()] = line.slice(splitAt + 1).trim()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function normalizeRepoName(value = '') {
|
||||
return value.trim().replace(/\/$/, '').replace(/\.git$/i, '').split('/').at(-1)?.replace(/^.*@/, '') || ''
|
||||
}
|
||||
|
||||
function normalizeUrl(value = '') {
|
||||
return value ? value.trim().replace(/\/+$/, '').toLowerCase() : ''
|
||||
}
|
||||
|
||||
export function selectUniqueProject(catalog, identity) {
|
||||
const expected = {
|
||||
projectName: identity.projectName.trim(),
|
||||
repoName: normalizeRepoName(identity.repoName),
|
||||
productionUrl: normalizeUrl(identity.productionUrl),
|
||||
deploymentName: identity.deploymentName.trim(),
|
||||
}
|
||||
const candidates = catalog.filter((project) => [
|
||||
expected.projectName && project.projectName === expected.projectName,
|
||||
expected.repoName && normalizeRepoName(project.repoName || '') === expected.repoName,
|
||||
expected.productionUrl && normalizeUrl(project.productionUrl || '') === expected.productionUrl,
|
||||
expected.deploymentName && (project.deploymentName || '').trim() === expected.deploymentName,
|
||||
expected.deploymentName && (project.infrastructure?.servers || []).some((server) => server.deploymentName === expected.deploymentName),
|
||||
].some(Boolean))
|
||||
if (candidates.length !== 1) fail(`PROJECT_MATCH_${candidates.length === 0 ? 'NOT_FOUND' : 'AMBIGUOUS'}`)
|
||||
const project = candidates[0]
|
||||
const conflicts = []
|
||||
if (project.projectName !== expected.projectName) conflicts.push('projectName')
|
||||
if (project.repoName && normalizeRepoName(project.repoName) !== expected.repoName) conflicts.push('repoName')
|
||||
if (project.productionUrl && normalizeUrl(project.productionUrl) !== expected.productionUrl) conflicts.push('productionUrl')
|
||||
if (project.deploymentName && project.deploymentName !== expected.deploymentName) conflicts.push('deploymentName')
|
||||
if (conflicts.length) fail(`PROJECT_MATCH_CONFLICT:${conflicts.join(',')}`)
|
||||
return project
|
||||
}
|
||||
|
||||
export function classifyCommit(subject, paths) {
|
||||
if (/^(?:merge|revert)\b|^(?:合并|回滚)/i.test(subject)) return { reason: 'merge_or_revert' }
|
||||
const subjectMatches = FEATURE_RULES.filter((rule) => rule.subject.test(subject))
|
||||
if (subjectMatches.length === 1) return { rule: subjectMatches[0] }
|
||||
if (subjectMatches.length > 1) {
|
||||
const highestPriority = Math.max(...subjectMatches.map((rule) => rule.priority))
|
||||
const strongest = subjectMatches.filter((rule) => rule.priority === highestPriority)
|
||||
if (strongest.length === 1) return { rule: strongest[0] }
|
||||
fail(`FEATURE_MATCH_AMBIGUOUS:${subject}`)
|
||||
}
|
||||
const pathMatches = FEATURE_RULES.filter((rule) => paths.some((filePath) => rule.paths.test(filePath)))
|
||||
if (pathMatches.length === 1) return { rule: pathMatches[0] }
|
||||
if (pathMatches.length > 1) fail(`FEATURE_MATCH_AMBIGUOUS:${subject}`)
|
||||
return { reason: 'no_clear_feature' }
|
||||
}
|
||||
|
||||
export function makeIdempotencyKey(repoName, sha, status) {
|
||||
if (!ALLOWED_STATUSES.has(status)) fail(`INVALID_STATUS:${status}`)
|
||||
return `${normalizeRepoName(repoName)}:${sha}:${status}`
|
||||
}
|
||||
|
||||
export function validateStatusEvidence(status, options) {
|
||||
if (!ALLOWED_STATUSES.has(status)) fail(`INVALID_STATUS:${status}`)
|
||||
if (status === 'testing' && !options.testStatus) fail('TESTING_REQUIRES_TEST_STATUS')
|
||||
if (status === 'done' && (options.testStatus !== 'passed' || !options.acceptanceEvidence)) {
|
||||
fail('DONE_REQUIRES_PASSED_TEST_AND_ACCEPTANCE_EVIDENCE')
|
||||
}
|
||||
}
|
||||
|
||||
function git(repoRoot, args) {
|
||||
const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
||||
if (result.status !== 0) fail(`GIT_FAILED:${args[0]}:${result.stderr.trim()}`)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
function getChangedFiles(repoRoot, sha) {
|
||||
const statuses = git(repoRoot, ['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-M', sha]).split('\n').filter(Boolean)
|
||||
const nums = git(repoRoot, ['diff-tree', '--root', '--no-commit-id', '--numstat', '-r', '-M', sha]).split('\n').filter(Boolean)
|
||||
if (statuses.length !== nums.length) fail(`GIT_FILE_STAT_MISMATCH:${sha}`)
|
||||
return statuses.map((line, index) => {
|
||||
const fields = line.split('\t')
|
||||
const status = fields[0][0]
|
||||
if (!['A', 'M', 'D', 'R'].includes(status)) fail(`UNSUPPORTED_FILE_STATUS:${fields[0]}`)
|
||||
const numFields = nums[index].split('\t')
|
||||
return {
|
||||
path: status === 'R' ? `${fields[1]} -> ${fields[2]}` : fields[1],
|
||||
status,
|
||||
additions: numFields[0] === '-' ? 0 : Number(numFields[0]),
|
||||
deletions: numFields[1] === '-' ? 0 : Number(numFields[1]),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getCommit(repoRoot, sha, branch) {
|
||||
const output = git(repoRoot, ['show', '-s', '--use-mailmap', '--format=%H%x00%aN%x00%aE%x00%cI%x00%s', sha])
|
||||
const [fullSha, authorName, authorEmail, committedAt, message] = output.split('\0')
|
||||
const changedFiles = getChangedFiles(repoRoot, sha)
|
||||
return {
|
||||
sha: fullSha,
|
||||
message,
|
||||
author_name: authorName,
|
||||
author_email: authorEmail,
|
||||
committed_at: committedAt,
|
||||
branch,
|
||||
changed_files: changedFiles,
|
||||
stats: {
|
||||
additions: changedFiles.reduce((sum, file) => sum + file.additions, 0),
|
||||
deletions: changedFiles.reduce((sum, file) => sum + file.deletions, 0),
|
||||
files_changed: changedFiles.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const values = {}
|
||||
const flags = new Set()
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) continue
|
||||
const splitAt = arg.indexOf('=')
|
||||
if (splitAt === -1) flags.add(arg.slice(2))
|
||||
else values[arg.slice(2, splitAt)] = arg.slice(splitAt + 1)
|
||||
}
|
||||
return { values, flags }
|
||||
}
|
||||
|
||||
async function apiRequest(base, apiKey, endpoint, options = {}) {
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${base}${endpoint}`, {
|
||||
...options,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', ...(options.headers || {}) },
|
||||
})
|
||||
} catch (error) {
|
||||
if (attempt < 3) {
|
||||
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 5000))
|
||||
continue
|
||||
}
|
||||
fail(`API_NETWORK_ERROR:${error instanceof Error ? error.message : 'request failed'}`)
|
||||
}
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (response.ok) return payload
|
||||
if (response.status === 429 && attempt < 3) {
|
||||
const retryAfter = Number(response.headers.get('retry-after'))
|
||||
const waitSeconds = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 30
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(waitSeconds + 1, 60) * 1000))
|
||||
continue
|
||||
}
|
||||
if ([502, 503, 504].includes(response.status) && attempt < 3) {
|
||||
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 5000))
|
||||
continue
|
||||
}
|
||||
const code = payload?.error?.code || 'UNKNOWN'
|
||||
const message = payload?.error?.message || ''
|
||||
const details = payload?.error?.mismatch || payload?.error?.details || payload?.error?.issues || payload?.details || payload?.issues
|
||||
fail(`API_${response.status}:${code}${message ? `:${message}` : ''}${details ? `:${JSON.stringify(details)}` : ''}`)
|
||||
}
|
||||
fail('API_RETRY_EXHAUSTED')
|
||||
}
|
||||
|
||||
function readState(statePath) {
|
||||
if (!fs.existsSync(statePath)) return { version: 1, synced: {} }
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'))
|
||||
return state?.version === 1 && state.synced && typeof state.synced === 'object' ? state : { version: 1, synced: {} }
|
||||
}
|
||||
|
||||
function writeState(statePath, state) {
|
||||
fs.mkdirSync(path.dirname(statePath), { recursive: true })
|
||||
const tempPath = `${statePath}.${process.pid}.tmp`
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
||||
fs.renameSync(tempPath, statePath)
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const { values, flags } = parseArgs(argv)
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = git(path.resolve(scriptDir, '..'), ['rev-parse', '--show-toplevel'])
|
||||
const privateDir = path.join(repoRoot, '.private', 'kaifa-progress-sync')
|
||||
const envPath = path.join(privateDir, '.env.local')
|
||||
const statePath = path.join(privateDir, 'state.json')
|
||||
if (!fs.existsSync(envPath)) fail(`CONFIG_NOT_FOUND:${envPath}`)
|
||||
const env = parseEnv(fs.readFileSync(envPath, 'utf8'))
|
||||
const apiBase = (env.API_BASE || DEFAULT_API_BASE).replace(/\/$/, '')
|
||||
if (!env.API_KEY) fail(`API_KEY_NOT_CONFIGURED:${envPath}`)
|
||||
for (const field of ['OWNER_EMAIL', 'DEPARTMENT', 'PROJECT', 'REPO_NAME', 'PRODUCTION_URL', 'DEPLOYMENT_NAME']) {
|
||||
if (!env[field]) fail(`CONFIG_FIELD_MISSING:${field}`)
|
||||
}
|
||||
const status = values.status || 'code_complete'
|
||||
const testStatus = values['test-status'] || (status === 'code_complete' ? 'not_run' : null)
|
||||
const acceptanceEvidence = values['acceptance-evidence'] || null
|
||||
validateStatusEvidence(status, { testStatus, acceptanceEvidence })
|
||||
const dryRun = flags.has('dry-run')
|
||||
const ref = values.ref || 'origin/main'
|
||||
const since = values.since || DEFAULT_SINCE
|
||||
const limit = values.limit ? Number(values.limit) : Number.POSITIVE_INFINITY
|
||||
if (!(limit > 0)) fail('INVALID_LIMIT')
|
||||
const remoteUrl = git(repoRoot, ['config', '--get', 'remote.origin.url'])
|
||||
if (normalizeRepoName(remoteUrl) !== normalizeRepoName(env.REPO_NAME)) fail('REPOSITORY_NAME_MISMATCH')
|
||||
const catalogResponse = await apiRequest(apiBase, env.API_KEY, '/my/catalog')
|
||||
const project = selectUniqueProject(catalogResponse.catalog || [], {
|
||||
projectName: env.PROJECT,
|
||||
repoName: env.REPO_NAME,
|
||||
productionUrl: env.PRODUCTION_URL,
|
||||
deploymentName: env.DEPLOYMENT_NAME,
|
||||
})
|
||||
if (project.department !== env.DEPARTMENT) fail('PROJECT_DEPARTMENT_MISMATCH')
|
||||
const state = readState(statePath)
|
||||
const shas = git(repoRoot, ['log', ref, `--since=${since}`, '--no-merges', '--reverse', '--format=%H']).split('\n').filter(Boolean)
|
||||
const excluded = {}
|
||||
const excludedCommits = []
|
||||
const excludedAuthors = {}
|
||||
const candidates = []
|
||||
for (const sha of shas) {
|
||||
const key = makeIdempotencyKey(env.REPO_NAME, sha, status)
|
||||
if (state.synced[key]) {
|
||||
excluded.already_synced = (excluded.already_synced || 0) + 1
|
||||
continue
|
||||
}
|
||||
const commit = getCommit(repoRoot, sha, 'main')
|
||||
if (commit.author_email.toLowerCase() !== env.OWNER_EMAIL.toLowerCase()) {
|
||||
excluded.other_developer = (excluded.other_developer || 0) + 1
|
||||
const author = `${commit.author_name} <${commit.author_email}>`
|
||||
excludedAuthors[author] = (excludedAuthors[author] || 0) + 1
|
||||
continue
|
||||
}
|
||||
const classification = classifyCommit(commit.message, commit.changed_files.map((file) => file.path))
|
||||
if (!classification.rule) {
|
||||
excluded[classification.reason] = (excluded[classification.reason] || 0) + 1
|
||||
excludedCommits.push({ sha: commit.sha, title: commit.message, reason: classification.reason })
|
||||
continue
|
||||
}
|
||||
candidates.push({ commit, rule: classification.rule, key })
|
||||
if (candidates.length >= limit) break
|
||||
}
|
||||
const featureSummary = Object.values(candidates.reduce((map, item) => {
|
||||
const name = `${item.rule.moduleName}/${item.rule.featureName}`
|
||||
map[name] ||= { module: item.rule.moduleName, feature: item.rule.featureName, commits: 0 }
|
||||
map[name].commits += 1
|
||||
return map
|
||||
}, {}))
|
||||
const authorSummary = Object.values(candidates.reduce((map, item) => {
|
||||
const key = `${item.commit.author_name} <${item.commit.author_email}>`
|
||||
map[key] ||= { author: key, commits: 0 }
|
||||
map[key].commits += 1
|
||||
return map
|
||||
}, {}))
|
||||
if (dryRun) {
|
||||
console.log(JSON.stringify({ mode: 'dry-run', project: project.projectName, ref, since, status, candidates: candidates.length, excluded, excludedAuthors, excludedCommits, features: featureSummary, authors: authorSummary }, null, 2))
|
||||
return
|
||||
}
|
||||
if (!candidates.length) {
|
||||
console.log(JSON.stringify({ mode: 'apply', project: project.projectName, synced: 0, excluded }, null, 2))
|
||||
return
|
||||
}
|
||||
const definitions = [...new Map(candidates.map(({ rule }) => [rule.key, rule])).values()]
|
||||
const resolved = await apiRequest(apiBase, env.API_KEY, `/projects/${project.projectId}/features/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': `${normalizeRepoName(env.REPO_NAME)}:feature-catalog:v1` },
|
||||
body: JSON.stringify({
|
||||
context: { department: env.DEPARTMENT, project: env.PROJECT, developer: env.OWNER_EMAIL || '' },
|
||||
items: definitions.map((rule) => ({ module_name: rule.moduleName, feature_name: rule.featureName, create_module: true })),
|
||||
}),
|
||||
})
|
||||
const featureIds = new Map()
|
||||
for (const item of resolved.items || []) {
|
||||
const rule = definitions.find((candidate) => candidate.moduleName === item.moduleName && candidate.featureName === item.featureName)
|
||||
if (!rule || !item.moduleId || !item.featureId || !item.canPush) fail(`FEATURE_NOT_PUSHABLE:${item.moduleName}/${item.featureName}`)
|
||||
featureIds.set(rule.key, { moduleId: item.moduleId, featureId: item.featureId })
|
||||
}
|
||||
if (featureIds.size !== definitions.length) fail('FEATURE_RESOLUTION_INCOMPLETE')
|
||||
let synced = 0
|
||||
const syncedByFeature = {}
|
||||
for (const candidate of candidates) {
|
||||
const ids = featureIds.get(candidate.rule.key)
|
||||
const commit = { ...candidate.commit, module_id: ids.moduleId, feature_id: ids.featureId }
|
||||
const update = {
|
||||
module_id: ids.moduleId,
|
||||
feature_id: ids.featureId,
|
||||
change_type: 'feature',
|
||||
status,
|
||||
summary: commit.message,
|
||||
blockers: null,
|
||||
next_steps: status === 'code_complete' ? '待测试' : status === 'testing' ? '待验收或发布' : '已完成验收或发布',
|
||||
evidence_shas: [commit.sha],
|
||||
test_status: testStatus,
|
||||
...(acceptanceEvidence ? { completion_evidence: acceptanceEvidence } : {}),
|
||||
}
|
||||
const response = await apiRequest(apiBase, env.API_KEY, `/projects/${project.projectId}/progress-sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': candidate.key },
|
||||
body: JSON.stringify({
|
||||
context: { department: env.DEPARTMENT, project: env.PROJECT, developer: env.OWNER_EMAIL || '' },
|
||||
commits: [commit],
|
||||
updates: [update],
|
||||
}),
|
||||
})
|
||||
if (!response.commits || !response.updates || !response.project) fail(`SYNC_RESPONSE_INCOMPLETE:${commit.sha}`)
|
||||
state.synced[candidate.key] = { syncedAt: new Date().toISOString(), projectId: project.projectId, moduleId: ids.moduleId, featureId: ids.featureId }
|
||||
state.lastSuccessfulAt = new Date().toISOString()
|
||||
writeState(statePath, state)
|
||||
synced += 1
|
||||
syncedByFeature[candidate.rule.key] = (syncedByFeature[candidate.rule.key] || 0) + 1
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100))
|
||||
}
|
||||
console.log(JSON.stringify({ mode: 'apply', project: project.projectName, ref, status, synced, excluded, excludedAuthors, excludedCommits, syncedByFeature }, null, 2))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
main().catch((error) => {
|
||||
const message = String(error?.message || error).replace(/ptrk_[A-Za-z0-9_-]+/g, '[REDACTED_API_KEY]')
|
||||
console.error(`progress-sync stopped: ${message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
42
tests/scripts/kaifa-progress-sync.test.mjs
Normal file
42
tests/scripts/kaifa-progress-sync.test.mjs
Normal file
@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
classifyCommit,
|
||||
makeIdempotencyKey,
|
||||
normalizeRepoName,
|
||||
selectUniqueProject,
|
||||
validateStatusEvidence,
|
||||
} from '../../scripts/kaifa-progress-sync.mjs'
|
||||
|
||||
test('normalizes repository names and builds a stable idempotency key', () => {
|
||||
assert.equal(normalizeRepoName('http://git.example.com/team/CaiWuXiTong.git'), 'CaiWuXiTong')
|
||||
assert.equal(makeIdempotencyKey('CaiWuXiTong', 'abc123', 'testing'), 'CaiWuXiTong:abc123:testing')
|
||||
})
|
||||
|
||||
test('matches exactly one project and rejects ambiguous matches', () => {
|
||||
const project = {
|
||||
projectId: 'p1', projectName: '财务系统', department: '财务中心', repoName: 'CaiWuXiTong',
|
||||
productionUrl: 'https://218.93.190.75:8442', deploymentName: 'caiwuxitong',
|
||||
}
|
||||
const identity = {
|
||||
projectName: '财务系统', repoName: 'CaiWuXiTong', productionUrl: 'https://218.93.190.75:8442',
|
||||
deploymentName: 'caiwuxitong',
|
||||
}
|
||||
assert.equal(selectUniqueProject([project], identity).projectId, 'p1')
|
||||
assert.throws(() => selectUniqueProject([project, { ...project, projectId: 'p2' }], identity), /AMBIGUOUS/)
|
||||
})
|
||||
|
||||
test('assigns one primary feature and stops on ambiguous feature matches', () => {
|
||||
assert.equal(classifyCommit('财务经营报表增加安装明细', ['app/Models/Business/InstallationDetail.php']).rule.key, 'installation-details')
|
||||
assert.equal(classifyCommit('修复云数据库管理不支持 PostgreSQL 连接', ['app/Http/Controllers/SystemSettings/Databases.php']).rule.key, 'upstream-connections')
|
||||
assert.throws(
|
||||
() => classifyCommit('chore: shared update', ['app/Utilities/SystemDatabaseConfiguration.php', 'modules/EcommerceFinance/Services/UpstreamOdbcConnection.php']),
|
||||
/FEATURE_MATCH_AMBIGUOUS/,
|
||||
)
|
||||
})
|
||||
|
||||
test('done requires passed tests and explicit acceptance evidence', () => {
|
||||
assert.throws(() => validateStatusEvidence('done', { testStatus: 'passed', acceptanceEvidence: '' }), /DONE_REQUIRES/)
|
||||
assert.doesNotThrow(() => validateStatusEvidence('done', { testStatus: 'passed', acceptanceEvidence: 'production SHA verified' }))
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user