devperf/backend/src/services/okr-ai-sync.ts
zyc e1396b1479
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
feat(okr): 接入豆包AI自动分析Git提交生成OKR
基于豆包(Doubao) LLM 分析 git commit messages,按仓库维度自动为每个
提交人生成、更新、标记完成 OKR:

- 新增 ai_analyzed_commits 表实现增量标记,每条 commit 只分析一次
- objectives/keyResults 新增 source、sourceKey 字段区分 AI 生成与手动创建
- keyResults.status 扩展支持 completed 状态
- 新增 llm-client.ts 封装豆包 Ark API 调用(原生 fetch,零依赖)
- 新增 okr-ai-sync.ts 核心服务:按仓库分组 → 构建 prompt → 调用 AI → 执行 actions
- scheduler 在 Git 同步后自动触发 AI 分析(受 AI_ENABLED 开关控制)
- 新增 POST /api/okr/ai-analyze 手动触发和 preview 预览端点
- 防重复三层保障:commit SHA 标记 + sourceKey 去重 + 项目 OKR 上下文

已验证:501 条 commits 全量分析,生成 37 个 Objectives、164 个 Key Results,
增量去重机制正常(重复调用返回 0 actions)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 13:29:36 +08:00

579 lines
20 KiB
TypeScript
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 { 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 中<><E4B8AD>存在 → 创建新 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<CommitGroup[]> {
// 获取所有已分析的 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<string, string>();
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<string, CommitGroup>();
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<string> {
// 获取该项目下所有现有 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<number> {
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<AnalyzeResult> {
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<AIResponse>(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 };
}