369 lines
20 KiB
TypeScript
369 lines
20 KiB
TypeScript
import type { CommitPlanInput, ComponentWorkflowInput, DbSyncPlanInput, ExecResult, FinishTaskInput, PushPlanInput, RemoteHealthCheckInput, RepoType, ResolvedProject } from '../types.js';
|
||
import { tryExecFile, tryPowerShell } from '../core/exec.js';
|
||
import { detectRepoType, parseGitStatusFiles } from '../core/project.js';
|
||
import { formatDateInShanghai, getGlobalReviewCommand, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js';
|
||
import { cleanupHistoryArtifacts } from './cleanup.js';
|
||
import { karpathyChangedFileChecklistItem, karpathyFinishNote, karpathyStopCondition } from './karpathy.js';
|
||
import { buildGlobalCodeReview, defaultReviewProjects } from './review.js';
|
||
import { buildVerificationPlan } from './verification.js';
|
||
import { buildWorkstreamCloseout } from './closeout.js';
|
||
interface RepoSnapshot {
|
||
branch: string;
|
||
changedFiles: string[];
|
||
project: ResolvedProject;
|
||
remote: string;
|
||
repoType: RepoType;
|
||
status: string;
|
||
}
|
||
|
||
function normalizeProjectKeys(projects?: string[]): string[] {
|
||
const selected = (projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects];
|
||
return Array.from(new Set(selected.filter((project) => project !== 'root')));
|
||
}
|
||
|
||
async function collectRepoSnapshot(projectKey: string): Promise<RepoSnapshot> {
|
||
const project = resolveProject(projectKey);
|
||
const repoType = detectRepoType(project.path);
|
||
const status = repoType === 'git'
|
||
? await tryExecFile('git', ['status', '--short'], project.path)
|
||
: null;
|
||
const branch = repoType === 'git'
|
||
? await tryExecFile('git', ['branch', '--show-current'], project.path)
|
||
: null;
|
||
const remote = repoType === 'git'
|
||
? await tryExecFile('git', ['remote', '-v'], project.path)
|
||
: null;
|
||
|
||
return {
|
||
branch: branch?.ok ? branch.stdout : '',
|
||
changedFiles: status?.ok ? parseGitStatusFiles(status.stdout) : [],
|
||
project,
|
||
remote: remote?.ok ? remote.stdout : '',
|
||
repoType,
|
||
status: status?.ok ? status.stdout : status?.stderr || '',
|
||
};
|
||
}
|
||
|
||
function suggestCommitMessage(snapshot: RepoSnapshot): string {
|
||
const files = snapshot.changedFiles.join('\n');
|
||
|
||
if (snapshot.project.alias === 'mcp') {
|
||
return 'feat(workflow): 扩展KT工作流能力';
|
||
}
|
||
if (snapshot.project.alias === 'api' && /qqbot/i.test(files)) {
|
||
return 'fix(qqbot): 修复QQBot运行链路';
|
||
}
|
||
if (snapshot.project.alias === 'admin' && /\.env|authentication/.test(files)) {
|
||
return 'fix(admin): 收敛环境配置和登录页';
|
||
}
|
||
if (snapshot.project.alias === 'web' && /deploy\.json|\.gitignore/.test(files)) {
|
||
return 'chore(web): 清理旧部署配置';
|
||
}
|
||
if (snapshot.project.alias === 'blog') {
|
||
return 'fix(blog): 优化博客前台体验';
|
||
}
|
||
if (snapshot.project.alias === 'knife4j') {
|
||
return 'feat(knife4j): 升级接口文档插件能力';
|
||
}
|
||
return `chore(${snapshot.project.alias || 'repo'}): 更新项目改动`;
|
||
}
|
||
|
||
export async function buildFinishTask(input: FinishTaskInput = {}): Promise<Record<string, unknown>> {
|
||
const projectKeys = normalizeProjectKeys(input.projects);
|
||
const snapshots = await Promise.all(projectKeys.map(collectRepoSnapshot));
|
||
const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0);
|
||
const verificationPlans = changedSnapshots.map((snapshot) =>
|
||
buildVerificationPlan({
|
||
changeType: input.changeType || 'general',
|
||
project: snapshot.project.alias || snapshot.project.relativePath,
|
||
}),
|
||
);
|
||
const validationRuns: Array<{
|
||
command: string;
|
||
project: ResolvedProject;
|
||
result: ExecResult;
|
||
}> = [];
|
||
|
||
if (input.runValidation) {
|
||
for (const plan of verificationPlans) {
|
||
for (const command of plan.commands.filter((item) => !item.includes('global-review'))) {
|
||
validationRuns.push({
|
||
command,
|
||
project: plan.project,
|
||
result: await tryPowerShell(command, plan.project.path),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const review = await buildGlobalCodeReview({
|
||
contentScanMode: input.contentScanMode ?? 'changed',
|
||
includeContentScan: true,
|
||
includeRootScan: input.includeRootScan ?? true,
|
||
maxFindingsPerProject: input.maxFindingsPerProject ?? 20,
|
||
projects: projectKeys,
|
||
});
|
||
const cleanup = cleanupHistoryArtifacts({
|
||
dryRun: !(input.cleanupHistory ?? false),
|
||
keep: input.keepHistory ?? 3,
|
||
});
|
||
|
||
return {
|
||
cleanup,
|
||
closeout: buildWorkstreamCloseout({
|
||
changedProjects: changedSnapshots.map((snapshot) => snapshot.project.relativePath),
|
||
problemRecords: [],
|
||
reusablePatterns: ['none'],
|
||
stableSolutions: [],
|
||
title: 'kt_finish_task 收尾审计',
|
||
verificationEvidence: validationRuns
|
||
.filter((item) => item.result.ok)
|
||
.map((item) => `${item.project.relativePath}: ${item.command}`),
|
||
}),
|
||
mode: input.runValidation ? 'validation_executed' : 'plan_only',
|
||
notes: [
|
||
'默认只生成收尾结果;runValidation=true 时才执行验证命令。',
|
||
'cleanupHistory=true 时才真实清理历史产物,否则仅预览。',
|
||
'提交和推送仍需用户明确要求后执行。',
|
||
karpathyFinishNote,
|
||
],
|
||
review,
|
||
snapshots,
|
||
stopConditions: [
|
||
'review.findings 存在 P1/P2 时先判定真实风险或工具误报;真实风险修代码,工具误报修 ktWorkflow,再复跑 kt_global_code_review。',
|
||
'validationRuns 中任一 result.ok=false 时先处理失败命令。',
|
||
'发现真实 env、生产 Secret 或非本次范围文件时停止提交。',
|
||
karpathyStopCondition,
|
||
],
|
||
summary: {
|
||
changedProjectCount: changedSnapshots.length,
|
||
changedProjects: changedSnapshots.map((snapshot) => snapshot.project.relativePath),
|
||
validationRunCount: validationRuns.length,
|
||
},
|
||
validationRuns,
|
||
verificationPlans,
|
||
};
|
||
}
|
||
|
||
export async function buildCommitPlan(input: CommitPlanInput = {}): Promise<Record<string, unknown>> {
|
||
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
|
||
const changedSnapshots = snapshots.filter((snapshot) => snapshot.changedFiles.length > 0);
|
||
|
||
return {
|
||
checks: [
|
||
'每个独立 Git 仓库单独提交。',
|
||
karpathyChangedFileChecklistItem,
|
||
'提交前先跑对应轻量验证和 kt_global_code_review。',
|
||
'kt_global_code_review findings 必须为 0;确认误报时先修 ktWorkflow 规则,不把误报当作长期上下文。',
|
||
'后端真实 .env.development/.env.production 不提交;前端 env 可提交但只能包含客户端公开配置。',
|
||
'KT 根目录是本地治理仓库;如没有 remote,只本地提交并在最终回复说明。',
|
||
],
|
||
includePush: input.includePush ?? false,
|
||
projects: changedSnapshots.map((snapshot) => {
|
||
const message = input.message || suggestCommitMessage(snapshot);
|
||
return {
|
||
branch: snapshot.branch,
|
||
changedFiles: snapshot.changedFiles,
|
||
commands: [
|
||
'git status --short',
|
||
'git add -A',
|
||
`git commit -m "${message}"`,
|
||
...(input.includePush ? [`git push origin ${snapshot.branch || 'main'}`] : []),
|
||
],
|
||
message,
|
||
project: snapshot.project,
|
||
status: snapshot.status,
|
||
};
|
||
}),
|
||
reviewCommand: getGlobalReviewCommand(),
|
||
summary: {
|
||
changedProjectCount: changedSnapshots.length,
|
||
totalChangedFiles: changedSnapshots.reduce(
|
||
(count, snapshot) => count + snapshot.changedFiles.length,
|
||
0,
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function buildPushPlan(input: PushPlanInput = {}): Promise<Record<string, unknown>> {
|
||
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
|
||
const remote = input.remote || 'origin';
|
||
|
||
return {
|
||
checks: [
|
||
'推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。',
|
||
'推送前复跑 kt_global_code_review,findings 必须为 0;如出现误报先升级 ktWorkflow 规则。',
|
||
'如果 Jenkins/Gitea host key、mirror、hooks 异常,先修远程仓库状态再重试。',
|
||
],
|
||
projects: snapshots
|
||
.filter((snapshot) => snapshot.repoType === 'git')
|
||
.map((snapshot) => {
|
||
const branch = input.branch || snapshot.branch || 'main';
|
||
return {
|
||
branch,
|
||
commands: [
|
||
'git status --short',
|
||
`git log --oneline ${remote}/${branch}..${branch}`,
|
||
`git push ${remote} ${branch}`,
|
||
],
|
||
project: snapshot.project,
|
||
remote: snapshot.remote,
|
||
status: snapshot.status,
|
||
};
|
||
}),
|
||
reviewCommand: getGlobalReviewCommand(),
|
||
};
|
||
}
|
||
|
||
function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{
|
||
command: string;
|
||
service: string;
|
||
}> {
|
||
const host = input.sshTarget || input.host || process.env.KT_NAS_SSH_TARGET || 'nas.kwitsukasa.top';
|
||
const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202);
|
||
const ssh = (remoteCommand: string) => `ssh -p ${port} ${host} "${remoteCommand}"`;
|
||
const allCommands: Record<string, string> = {
|
||
docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"),
|
||
gitea: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i gitea || true"),
|
||
jenkins: ssh("docker ps --format '{{.Names}} {{.Status}}' | grep -i jenkins || true"),
|
||
k3d: ssh('kubectl get pods -A -o wide 2>/dev/null || k3d cluster list 2>/dev/null || true'),
|
||
minio: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i minio || true"),
|
||
mosquitto: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i mosquitto || true"),
|
||
mysql: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'mysql|mariadb' || true"),
|
||
napcat: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'napcat|qqbot' || true"),
|
||
ssh: ssh('hostname && date'),
|
||
};
|
||
const services = (input.services?.length ?? 0) > 0 ? input.services! : Object.keys(allCommands);
|
||
|
||
return services
|
||
.filter((service) => allCommands[service])
|
||
.map((service) => ({
|
||
command: allCommands[service],
|
||
service,
|
||
}));
|
||
}
|
||
|
||
export async function buildRemoteHealthCheck(input: RemoteHealthCheckInput = {}): Promise<Record<string, unknown>> {
|
||
const commands = buildRemoteHealthCommands(input);
|
||
const results = input.execute
|
||
? await Promise.all(
|
||
commands.map(async (item) => ({
|
||
...item,
|
||
result: await tryPowerShell(item.command, workspaceRoot),
|
||
})),
|
||
)
|
||
: [];
|
||
|
||
return {
|
||
commands,
|
||
execute: input.execute ?? false,
|
||
notes: [
|
||
'默认 execute=false,只生成只读探测命令。',
|
||
'远程命令只做 hostname/date/docker ps/kubectl get 等只读检查。',
|
||
'真实密码、token、Secret 不从仓库读取,也不写入输出。',
|
||
],
|
||
results,
|
||
};
|
||
}
|
||
|
||
export function buildDbSyncPlan(input: DbSyncPlanInput = {}): Record<string, unknown> {
|
||
const source = input.source || 'local';
|
||
const target = input.target || 'remote';
|
||
const backupName = input.backupName || `kt_template_backup_${formatDateInShanghai().replaceAll('-', '')}`;
|
||
|
||
return {
|
||
backupName,
|
||
commands: [
|
||
'从 Node/kt-template-online-api/.env.development 和 .env.production 读取连接信息,只在本机 shell 环境使用,不写入 Git。',
|
||
'KT 当前约定:127.0.0.1:3306 是本地 MySQL,127.0.0.1:3307 是远程 NAS 打洞连接;3307 不通时先建立 ssh -N -L 127.0.0.1:3307:127.0.0.1:3306 nas。',
|
||
'mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <source-conn> kt_template > .codex-db-sync/kt_template.sql',
|
||
`mysql <target-conn> -e "CREATE DATABASE IF NOT EXISTS ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`,
|
||
`mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <target-conn> kt_template | mysql <target-conn> ${backupName}`,
|
||
'mysql <target-conn> kt_template < .codex-db-sync/kt_template.sql',
|
||
'对比源库/目标库表数量、视图/函数/过程数量和关键表行数。',
|
||
`验证通过后再执行:mysql <target-conn> -e "DROP DATABASE ${backupName};"`,
|
||
],
|
||
guardrails: [
|
||
'默认只生成计划,不执行覆盖、drop、truncate。',
|
||
'同步前必须确认 source/target 方向,不能靠变量名猜。',
|
||
'3306/3307 端口方向先用只读 SELECT 预检确认,避免把远程库误当成本地库。',
|
||
'目标库备份校验通过前不得删除备份库。',
|
||
'涉及生产库时优先只读验证,批量删除必须二次确认。',
|
||
],
|
||
source,
|
||
target,
|
||
verification: [
|
||
'SHOW TABLES 数量一致。',
|
||
'admin_user、admin_menu、qqbot_account、qqbot_message_log 等关键表行数符合预期。',
|
||
'应用登录和核心接口 smoke 通过。',
|
||
'全局 review findings=0。',
|
||
],
|
||
};
|
||
}
|
||
|
||
export function buildComponentWorkflow(input: ComponentWorkflowInput = {}): Record<string, unknown> {
|
||
const target = input.target || 'KtTable';
|
||
const workflows = {
|
||
AdminAuth: {
|
||
antiPatterns: ['无 token 时主动 refresh', '跨端回跳时清空 Admin 已有授权态', '把 redirect 写丢或重复编码'],
|
||
files: ['Vue/kt-template-admin/apps/web-antdv-next/src/store/auth.ts', 'Vue/kt-template-online-web/src/api/auth.ts', 'Vue/kt-template-online-playground/src/api/auth.ts'],
|
||
guardrails: ['先画清楚 Admin/API/Web/Playground token 链路', '401 时先判断旧 token 是否存在', 'Admin 已登录时直接带回授权态'],
|
||
verification: ['清空 token 测无循环', 'Admin 已登录测直接回跳', '刷新页面测持久化'],
|
||
},
|
||
BlogArgon: {
|
||
antiPatterns: ['使用具名 id 控制 DOM', 'SCSS 类名逃出 kt-blog 命名空间', '主题变量散落在 scss 文件', '亮色模式仍套暗色遮罩'],
|
||
files: ['Vue/kt-blog-web/src/hooks/useBlogTheme.ts', 'Vue/kt-blog-web/src/styles', 'Vue/kt-blog-web/src/components/blog'],
|
||
guardrails: ['样式统一 kt-blog BEM 命名空间', '主题变量收敛到 useBlogTheme', '动画用 Vue Transition', '和线上 Argon DOM/视觉逐项对照'],
|
||
verification: ['亮/暗模式切换', '搜索 Enter 和动画', '文章切换动画', '无感知滚动加载', '背景图滚动到底部不断层'],
|
||
},
|
||
Knife4jSwagger: {
|
||
antiPatterns: ['视觉只大致相似', 'TSX 里用 function render helper', 'TabPane 默认插槽不显式传 default', '把请求入参编辑拆成无法复用的零散状态'],
|
||
files: ['Plugins/knife4j-swagger-vue3/packages/knife4j-vue3-ui', 'Plugins/knife4j-swagger-vue3/packages/openapi-parser', 'Plugins/knife4j-swagger-vue3/apps/playground'],
|
||
guardrails: ['以 Knife4j 原始 UI 为 1:1 视觉源', '全部 Vue 视图使用 TSX', '嵌套组件 slot 显式 default', '路由使用 hash path 参数,不用 query 兼容旧逻辑', '调试台按 OpenAPI 标准合并 path/query/header/body/auth/global 参数'],
|
||
verification: ['pnpm run typecheck', '真实后端 48085 文档页面', '控制台 warning/error 检查', 'Body/Headers/Auth/全局参数发送链路', '小宽度 @media 与左侧调试栏固定'],
|
||
},
|
||
KtTable: {
|
||
antiPatterns: ['自定义纵向滚动条覆盖 Antdv 原生滚动', 'scroll/resize 高频写响应式状态', '表格高度参与展开收起动画', '固定列透明导致透视'],
|
||
files: ['Vue/kt-template-admin/apps/web-antdv-next/src/components/ktTable'],
|
||
guardrails: ['优先使用 antdv-next 原生 table 能力', '统计列固定到底部但不参与表单动画', '序号列和操作列固定最小/最大宽', 'resize 用 RAF 节流和非深度监听'],
|
||
verification: ['横向滚动性能', '纵向滚动表头对齐', '列宽/行高 resize', 'summary 固定底部', '宽屏无无意义横向滚动条'],
|
||
},
|
||
QQBot: {
|
||
antiPatterns: ['把 NapCat token 写进仓库', '删除账号不清理专属容器', '白名单/黑名单只做前端过滤', '远程服务不可达时伪装成业务成功', '更新登录时用旧 isLogin 状态短路'],
|
||
files: ['Node/kt-template-online-api/src/qqbot', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot', 'ci/fnos-k8s/deploy-qqbot-runtime.ps1'],
|
||
guardrails: ['OneBot 反向 WS token 校验', '权限名单底层先走 QQ号过滤', '群/频道精确 QQ 过滤由开关控制', '扫码取消/过期/失败清理未绑定容器', '更新登录走 SSE 进度,后台重置登录态后再生成二维码'],
|
||
verification: ['账号在线', '扫码新增/更新登录', 'scan/events 每步进度', '自动回复规则命中', '权限名单三类过滤', '消息日志和发送日志闭环'],
|
||
},
|
||
FF14Plugin: {
|
||
antiPatterns: ['把 boss/服务器/职业映射硬编码到插件里', 'FFLogs 英文任务名直接回显给中文用户', '外部 API 失败时吞掉错误上下文', '字典缺失时返回空白结果'],
|
||
files: ['Node/kt-template-online-api/src/qqbot/plugins/ff14Market', 'Node/kt-template-online-api/src/qqbot/plugins/fflogs', 'Node/kt-template-online-api/src/admin/dict', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/system/dict'],
|
||
guardrails: ['FF14 查价和 FFLogs 映射优先走字典表维护', '中文任务名查询和回显都要经过字典', '命令注册表和在线命令列表同步验证', '保留最近 logs 的颜色/评分/dps/adps/rdps/ndps/hps 字段语义'],
|
||
verification: ['/logs 角色 服务器 高难任务中文名', '/logs 角色 服务器 最近10次', 'FF14 查价中文物品名', '字典管理页面可维护映射'],
|
||
},
|
||
FnosK8s: {
|
||
antiPatterns: ['把 fnOS 原生面板改成 Docker 应用', '在 ui/config 显式配置公网端口', '提交 _build/_dist 打包产物', '只测桌面视口不测 fnOS 内嵌窗口和窄屏', '用非官方或旧缓存图标替代 Kubernetes 官方图标'],
|
||
files: ['Plugins/fnos-k8s-dashboard-fpk/src/server.ts', 'Plugins/fnos-k8s-dashboard-fpk/src/ui', 'Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard/fnos', 'Plugins/fnos-k8s-dashboard-fpk/scripts'],
|
||
guardrails: ['保持非 Docker .fpk 形态', 'Web 面板源码只放 src/ui,由打包脚本复制进 app.tgz', 'fnOS 桌面入口使用 ui/config iframe 和 /kt-k8s-dashboard/ 代理路径', '应用标题与图标保持 Kubernetes 官方语义', '集群配置文件只作为运行时数据,不提交真实 kubeconfig'],
|
||
verification: ['bun run typecheck', 'bun run build:fpk', 'bun run verify:fpk', '本地 /api/health 和首页请求', 'Playwright 桌面与 390px 窄屏截图', '线上应用中心/桌面入口只在用户允许远程测试时验证'],
|
||
},
|
||
NapCatLogin: {
|
||
antiPatterns: ['更新登录复用已过期二维码 URL', '前端等待长接口导致超时', '手动刷新每次重启容器', '后端拿旧 login state 判断成功'],
|
||
files: ['Node/kt-template-online-api/src/qqbot/account', 'Node/kt-template-online-api/src/qqbot/napcat', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot'],
|
||
guardrails: ['创建账号和更新登录共用同一套二维码生成事实源', '更新登录先 reset runtime login state', 'SSE 返回 step/status/qrcode/error', '容器重启只在必要时触发,优先直接通信 NapCat API'],
|
||
verification: ['账号被踢下线后点击更新登录', 'scan/events 能看到重置/生成二维码/等待扫码/成功或失败', '手动刷新不超时', '过期二维码不再复用'],
|
||
},
|
||
SystemLog: {
|
||
antiPatterns: ['把真实 Loki 凭据写入仓库', '只改 Dockerfile 不校验线上 env/Secret', '日志时间格式前后端不一致', 'global-review 把历史脱敏占位符或中文显示标签当作真实凭据输出'],
|
||
files: ['Node/kt-template-online-api/src/common/logger', 'Node/kt-template-online-api/src/admin/system-log', 'Vue/kt-template-admin/apps/web-antdv-next/src/views/system/log', 'mcp/ktWorkflow/src/tools/review.ts'],
|
||
guardrails: ['pino + Loki env 走 Jenkins 私有 env 或 K8s Secret', '后端时间字段经 DTO/Entity 装饰器统一 YYYY-MM-DD HH:mm:ss', '系统日志页面走 /system/logs 系列接口', 'review 默认只对变更文件做 P2/P3 内容扫描,确认误报要沉淀到 review.ts'],
|
||
verification: ['API typecheck/lint/test', 'Loki /ready 和 query_range', 'Admin #/system/logs 无 console error', 'global-review findings 不含已知脱敏占位符或中文显示标签误报'],
|
||
},
|
||
} satisfies Record<string, Record<string, unknown>>;
|
||
|
||
return {
|
||
target,
|
||
workflow: workflows[target],
|
||
};
|
||
}
|