import { v4 as uuid } from 'uuid'; import { eq, isNull, sql, inArray } from 'drizzle-orm'; import { db } from '../db/index'; import { gitCommits, aiAnalyzedCommits, projectRepos, projects, objectives, keyResults, krLogs, syncLogs, users, } from '../db/schema'; import { callLLM, parseLLMJson } from './llm-client'; import { dateToPeriod, recalcObjectiveProgress } from './okr'; import dayjs from 'dayjs'; // ── Types ── interface AIAction { type: 'create_objective' | 'create_kr' | 'update_progress' | 'complete_kr'; ownerId?: string; objectiveId?: string; krId?: string; title?: string; sourceKey?: string; currentValue?: number; newCurrentValue?: number; startDate?: string; endDate?: string; reasoning?: string; keyResults?: { title: string; sourceKey: string; currentValue: number; startDate?: string; endDate?: string; }[]; } interface AIResponse { actions: AIAction[]; summary: string; } interface CommitGroup { repoName: string; projectId: string; projectName: string; commits: { sha: string; authorName: string | null; userId: string | null; message: string | null; additions: number; deletions: number; committedAt: Date; }[]; } // ── System Prompt ── const SYSTEM_PROMPT = `你是一个开发团队的 OKR 管理助手。你的任务是分析 git commit 记录,管理项目的 OKR(目标与关键成果)。 你需要分析提交记录,输出 JSON 格式的操作指令: 判断逻辑: - commit 包含 "完成"、"done"、"finished"、"实现完毕" 等完成语义 → 对应 KR 标记完成 - commit 是 feat/feature 类型但未完成 → 更新对应 KR 进度(根据描述估算百分比) - commit 涉及的功能在现有 KR 中��存在 → 创建新 KR(需同时提供时间节点) - fix/refactor/chore/docs 类 commit → 只在涉及明确功能时才操作 - 不要为同一个功能创建重复的 KR,已有相同 sourceKey 的直接更新 新建 Objective 规则: - 如果提交涉及的功能没有归属到任何已有 Objective → 可以创建新 Objective - Objective 的 startDate 和 endDate 由你根据提交内容和功能复杂度判断 - period 格式为 "YYYY-Qn"(如 "2026-Q2"),根据 endDate 推算 新建 KR 规则: - targetValue = 100, unit = "%" - 根据 commit 语义估算 currentValue(初步实现=30, 基本完成=70, 完成=100) - sourceKey 用小写英文短横线格式(如 "user-login", "payment-module") - title 用中文描述 - startDate 和 endDate 由你根据功能复杂度和提交时间判断 每个 action 中的 ownerId 必须是提交人的 userId,表示该 OKR 归谁负责。`; // ── Core Functions ── /** * 获取未分析的 commits,按 repoName 分组 */ async function gatherUnanalyzedCommits(): Promise { // 获取所有已分析的 SHA const analyzed = await db.select({ sha: aiAnalyzedCommits.commitSha }).from(aiAnalyzedCommits); const analyzedSet = new Set(analyzed.map(a => a.sha)); // 获取所有 commits(有 userId 的,且 4 月份及之后的) const cutoffDate = new Date('2026-01-01T00:00:00'); const allCommits = await db.select().from(gitCommits); const unanalyzed = allCommits.filter(c => c.userId && !analyzedSet.has(c.sha) && new Date(c.committedAt) >= cutoffDate ); if (unanalyzed.length === 0) return []; // 获取 projectRepos 映射 repoName → projectId const bindings = await db.select().from(projectRepos); const repoToProject = new Map(); for (const b of bindings) { // 支持多种格式的 repoName 匹配 const name = extractRepoName(b.repoName); repoToProject.set(name, b.projectId); } // 获取 projects 名称 const allProjects = await db.select().from(projects); const projectMap = new Map(allProjects.map(p => [p.id, p.name])); // 按 repoName 分组 const groups = new Map(); for (const commit of unanalyzed) { const projectId = repoToProject.get(commit.repoName); if (!projectId) continue; // 仓库未绑定项目,跳过 if (!groups.has(commit.repoName)) { groups.set(commit.repoName, { repoName: commit.repoName, projectId, projectName: projectMap.get(projectId) || commit.repoName, commits: [], }); } groups.get(commit.repoName)!.commits.push({ sha: commit.sha, authorName: commit.authorName, userId: commit.userId, message: commit.message, additions: commit.additions || 0, deletions: commit.deletions || 0, committedAt: commit.committedAt, }); } // 按时间排序每组的 commits for (const group of groups.values()) { group.commits.sort((a, b) => new Date(a.committedAt).getTime() - new Date(b.committedAt).getTime()); } return Array.from(groups.values()); } /** * 从各种格式中提取纯仓库名 */ function extractRepoName(input: string): string { let cleaned = input.trim().replace(/\.git$/, ''); if (cleaned.includes('://')) { try { const url = new URL(cleaned); const parts = url.pathname.split('/').filter(Boolean); return parts.length >= 2 ? parts[1] : parts[0]; } catch { /* fall through */ } } if (cleaned.includes('/')) { return cleaned.split('/').pop()!; } return cleaned; } /** * 构建 AI prompt */ async function buildUserPrompt(group: CommitGroup): Promise { // 获取该项目下所有现有 OKR const existingObjs = await db.select().from(objectives) .where(eq(objectives.projectId, group.projectId)); // 获取开发人员列表(从 commits 中提取 unique userId) const authorIds = [...new Set(group.commits.map(c => c.userId).filter(Boolean))] as string[]; const authorUsers = authorIds.length > 0 ? await db.select().from(users).where(inArray(users.id, authorIds)) : []; const userMap = new Map(authorUsers.map(u => [u.id, u.displayName])); let prompt = `仓库:${group.repoName}\n所属项目:${group.projectName}\n当前日期:${dayjs().format('YYYY-MM-DD')}\n\n`; // 开发人员列表 prompt += `该仓库的开发人员(commit 提交人 → 系统用户映射):\n`; for (const userId of authorIds) { prompt += `- userId: "${userId}", 姓名: ${userMap.get(userId) || '未知'}\n`; } prompt += '\n'; // 已有 OKR if (existingObjs.length > 0) { prompt += '该项目已有的 OKR:\n'; for (const obj of existingObjs) { const owner = obj.ownerId ? userMap.get(obj.ownerId) : null; prompt += `Objective: id="${obj.id}", title="${obj.title}", 负责人=${owner || '未指定'}, period=${obj.period}, 时间=${obj.startDate || '?'}~${obj.endDate || '?'}, 进度=${obj.progress || 0}%\n`; const krs = await db.select().from(keyResults) .where(eq(keyResults.objectiveId, obj.id)); if (krs.length > 0) { prompt += ' Key Results:\n'; for (const kr of krs) { prompt += ` - id: "${kr.id}", title: "${kr.title}", sourceKey: "${kr.sourceKey || ''}", status: ${kr.status}, 进度: ${kr.currentValue || 0}/${kr.targetValue}, 时间: ${kr.startDate || '?'}~${kr.endDate || '?'}\n`; } } } } else { prompt += '该项目暂无 OKR 记录。\n'; } // 新增 commits prompt += `\n该仓库新增的提交记录(按时间排序,均为增量,之前的已处理过):\n`; for (const commit of group.commits) { const date = dayjs(commit.committedAt).format('YYYY-MM-DD HH:mm'); const sha7 = commit.sha.substring(0, 7); const name = commit.authorName || '未知'; const userId = commit.userId || '?'; prompt += `- [${date}] 提交人: ${name}(userId=${userId}) ${sha7}: ${commit.message || '(无消息)'} (+${commit.additions}/-${commit.deletions})\n`; } prompt += `\n请分析以上提交,返回 JSON。注意:每个 action 都必须带 ownerId 字段,值为提交人的 userId,表示该 OKR 归谁负责。 { "actions": [ { "type": "create_objective", "ownerId": "提交人的userId", "title": "目标标题", "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD", "reasoning": "为什么要创建这个目标", "keyResults": [ { "title": "...", "sourceKey": "...", "currentValue": 30, "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" } ] }, { "type": "create_kr", "ownerId": "提交人的userId", "objectiveId": "已有目标的id", "title": "...", "sourceKey": "...", "currentValue": 30, "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD", "reasoning": "..." }, { "type": "update_progress", "krId": "已有KR的id", "newCurrentValue": 70, "reasoning": "..." }, { "type": "complete_kr", "krId": "已有KR的id", "reasoning": "..." } ], "summary": "一句话总结该仓库近期开发动态" } 如果没有需要操作的内容,返回 {"actions": [], "summary": "..."}`; return prompt; } /** * 执行 AI 返回的 actions */ async function executeActions(actions: AIAction[], projectId: string): Promise { let executedCount = 0; const now = new Date(); for (const action of actions) { try { switch (action.type) { case 'create_objective': { if (!action.title || !action.ownerId) break; const objId = uuid(); // 计算当前季度末 const now2 = dayjs(); const currentQuarter = Math.ceil((now2.month() + 1) / 3); const quarterEnd = dayjs(`${now2.year()}-${currentQuarter * 3}-01`).endOf('month'); const endDate = action.endDate || quarterEnd.format('YYYY-MM-DD'); const period = dateToPeriod(endDate); await db.insert(objectives).values({ id: objId, title: action.title, ownerId: action.ownerId, projectId, period, startDate: action.startDate || dayjs().format('YYYY-MM-DD'), endDate, progress: 0, source: 'ai_generated', createdAt: now, updatedAt: now, }); // 创建附带的 KRs if (action.keyResults?.length) { for (const krData of action.keyResults) { // sourceKey 去重检查 if (krData.sourceKey) { const existing = await db.select().from(keyResults) .where(eq(keyResults.sourceKey, krData.sourceKey)); if (existing.some(kr => kr.objectiveId === objId)) continue; } const krId = uuid(); await db.insert(keyResults).values({ id: krId, objectiveId: objId, title: krData.title, targetValue: 100, currentValue: krData.currentValue || 0, unit: '%', weight: 1, status: krData.currentValue >= 100 ? 'completed' : 'active', startDate: krData.startDate || action.startDate, endDate: krData.endDate || action.endDate, source: 'ai_generated', sourceKey: krData.sourceKey || null, createdAt: now, updatedAt: now, }); await addAILog(krId, 'created', `AI 自动创建: ${action.reasoning || ''}`); executedCount++; } } await recalcObjectiveProgress(objId); executedCount++; console.info(`[AI-OKR] Created objective: ${action.title}`); break; } case 'create_kr': { if (!action.objectiveId || !action.title) break; // 验证 objective 存在 const obj = await db.query.objectives.findFirst({ where: eq(objectives.id, action.objectiveId), }); if (!obj) { console.warn(`[AI-OKR] Objective ${action.objectiveId} not found, skipping create_kr`); break; } // sourceKey 去重 if (action.sourceKey) { const existing = await db.select().from(keyResults) .where(eq(keyResults.sourceKey, action.sourceKey)); if (existing.some(kr => kr.objectiveId === action.objectiveId)) { // 已存在同 sourceKey,转为更新进度 const existingKR = existing.find(kr => kr.objectiveId === action.objectiveId)!; if (action.currentValue && action.currentValue > (existingKR.currentValue || 0)) { await db.update(keyResults) .set({ currentValue: action.currentValue, updatedAt: now }) .where(eq(keyResults.id, existingKR.id)); await addAILog(existingKR.id, 'progress_update', `AI 更新(sourceKey 重复转更新): ${existingKR.currentValue} → ${action.currentValue}`); await recalcObjectiveProgress(action.objectiveId); } executedCount++; break; } } const krId = uuid(); await db.insert(keyResults).values({ id: krId, objectiveId: action.objectiveId, title: action.title, targetValue: 100, currentValue: action.currentValue || 0, unit: '%', weight: 1, status: (action.currentValue || 0) >= 100 ? 'completed' : 'active', startDate: action.startDate || null, endDate: action.endDate || null, source: 'ai_generated', sourceKey: action.sourceKey || null, createdAt: now, updatedAt: now, }); await addAILog(krId, 'created', `AI 自动创建: ${action.reasoning || ''}`); await recalcObjectiveProgress(action.objectiveId); executedCount++; console.info(`[AI-OKR] Created KR: ${action.title}`); break; } case 'update_progress': { if (!action.krId || action.newCurrentValue === undefined) break; const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, action.krId), }); if (!kr) { console.warn(`[AI-OKR] KR ${action.krId} not found, skipping update_progress`); break; } // 只允许进度前进,不允许后退 if (action.newCurrentValue <= (kr.currentValue || 0)) break; const clampedValue = Math.min(action.newCurrentValue, kr.targetValue); await db.update(keyResults) .set({ currentValue: clampedValue, updatedAt: now }) .where(eq(keyResults.id, action.krId)); await addAILog(action.krId, 'progress_update', `AI 更新进度: ${kr.currentValue} → ${clampedValue},${action.reasoning || ''}`); await recalcObjectiveProgress(kr.objectiveId); executedCount++; console.info(`[AI-OKR] Updated KR progress: ${kr.title} → ${clampedValue}`); break; } case 'complete_kr': { if (!action.krId) break; const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, action.krId), }); if (!kr) { console.warn(`[AI-OKR] KR ${action.krId} not found, skipping complete_kr`); break; } if (kr.status === 'completed') break; // 已完成则跳过 await db.update(keyResults) .set({ status: 'completed', currentValue: kr.targetValue, updatedAt: now }) .where(eq(keyResults.id, action.krId)); await addAILog(action.krId, 'completed', `AI 判定完成: ${action.reasoning || ''}`); await recalcObjectiveProgress(kr.objectiveId); executedCount++; console.info(`[AI-OKR] Completed KR: ${kr.title}`); break; } } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[AI-OKR] Error executing action ${action.type}: ${msg}`); } } return executedCount; } /** * 写 KR 操作日志(AI 系统操作) */ async function addAILog(krId: string, action: string, detail: string) { await db.insert(krLogs).values({ id: uuid(), krId, action, detail, operatorId: null, operatorName: 'AI System', createdAt: new Date(), }); } /** * 标记 commits 为已分析 */ async function markCommitsAnalyzed(shas: string[], batchId: string) { const now = new Date(); for (const sha of shas) { try { await db.insert(aiAnalyzedCommits).values({ id: uuid(), commitSha: sha, batchId, createdAt: now, }); } catch { // 可能已经存在(unique constraint),忽略 } } } // ── Main Entry ── export interface AnalyzeResult { totalCommits: number; reposProcessed: number; actionsExecuted: number; summaries: { repo: string; summary: string }[]; } /** * 分析未处理的 commits 并自动更新 OKR * @param dryRun 如果为 true,只返回 AI 建议不执行 */ export async function analyzeCommitsForOKR(dryRun = false): Promise { const startTime = Date.now(); const batchId = uuid(); const groups = await gatherUnanalyzedCommits(); if (groups.length === 0) { console.info('[AI-OKR] No unanalyzed commits found, skipping'); return { totalCommits: 0, reposProcessed: 0, actionsExecuted: 0, summaries: [] }; } let totalCommits = 0; let actionsExecuted = 0; const summaries: { repo: string; summary: string }[] = []; const MAX_COMMITS_PER_BATCH = 30; for (const group of groups) { try { totalCommits += group.commits.length; // 分批处理,每批最多 30 条 commits const batches: typeof group.commits[] = []; for (let i = 0; i < group.commits.length; i += MAX_COMMITS_PER_BATCH) { batches.push(group.commits.slice(i, i + MAX_COMMITS_PER_BATCH)); } for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { const batchCommits = batches[batchIdx]; const batchGroup = { ...group, commits: batchCommits }; console.info(`[AI-OKR] Analyzing ${batchCommits.length} commits for repo: ${group.repoName} (batch ${batchIdx + 1}/${batches.length})`); // 构建 prompt 并调用 AI const userPrompt = await buildUserPrompt(batchGroup); const rawResponse = await callLLM(SYSTEM_PROMPT, userPrompt); const aiResponse = parseLLMJson(rawResponse); if (!aiResponse.actions || !Array.isArray(aiResponse.actions)) { console.warn(`[AI-OKR] Invalid AI response for ${group.repoName}, skipping batch`); await markCommitsAnalyzed(batchCommits.map(c => c.sha), batchId); continue; } if (batchIdx === batches.length - 1) { summaries.push({ repo: group.repoName, summary: aiResponse.summary || '' }); } if (!dryRun) { const count = await executeActions(aiResponse.actions, group.projectId); actionsExecuted += count; await markCommitsAnalyzed(batchCommits.map(c => c.sha), batchId); } else { console.info(`[AI-OKR] [DRY RUN] Would execute ${aiResponse.actions.length} actions for ${group.repoName}`); actionsExecuted += aiResponse.actions.length; } } } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[AI-OKR] Failed to analyze repo ${group.repoName}: ${msg}`); } } // 记录 sync log const elapsed = Date.now() - startTime; await db.insert(syncLogs).values({ id: uuid(), source: 'ai_okr', status: 'success', message: `${dryRun ? '[DRY RUN] ' : ''}Analyzed ${totalCommits} commits from ${groups.length} repos, executed ${actionsExecuted} actions in ${elapsed}ms`, recordsProcessed: actionsExecuted, syncedAt: new Date(), }); console.info(`[AI-OKR] Completed: ${totalCommits} commits, ${groups.length} repos, ${actionsExecuted} actions in ${elapsed}ms`); return { totalCommits, reposProcessed: groups.length, actionsExecuted, summaries }; }