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

343 lines
12 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 { eq } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { db } from '../db/index';
import { objectives, keyResults, users, projects, krLogs } from '../db/schema';
import { desc } from 'drizzle-orm';
import { AppError } from '../middleware/error-handler';
export async function getOKRByPeriod(period?: string) {
const allObjectives = period
? await db.select().from(objectives).where(eq(objectives.period, period))
: await db.select().from(objectives);
const result = [];
for (const obj of allObjectives) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, obj.id));
const owner = obj.ownerId
? await db.query.users.findFirst({ where: eq(users.id, obj.ownerId) })
: null;
const project = obj.projectId
? await db.query.projects.findFirst({ where: eq(projects.id, obj.projectId) })
: null;
result.push({
id: obj.id,
title: obj.title,
ownerName: owner?.displayName || '未指定',
projectId: obj.projectId || null,
projectName: project?.name || '未关联项目',
period: obj.period,
startDate: obj.startDate || null,
endDate: obj.endDate || null,
progress: obj.progress || 0,
keyResults: await Promise.all(krs.map(async kr => {
// 查是否有延期记录
const postponeLog = await db.select().from(krLogs)
.where(eq(krLogs.krId, kr.id))
.orderBy(desc(krLogs.createdAt))
.limit(5);
const wasPostponed = postponeLog.some(l => l.action === 'postponed');
const lastPostponeReason = postponeLog.find(l => l.action === 'postponed')?.detail || null;
return {
id: kr.id,
title: kr.title,
targetValue: kr.targetValue,
currentValue: kr.currentValue || 0,
unit: kr.unit || '',
weight: kr.weight || 1,
status: kr.status || 'active',
wasPostponed,
lastPostponeReason,
startDate: kr.startDate || null,
endDate: kr.endDate || null,
progress: kr.targetValue > 0
? Math.round(((kr.currentValue || 0) / kr.targetValue) * 100)
: 0,
};
})),
});
}
return { objectives: result };
}
/**
* 从日期自动推算所属季度,例如 2026-04-15 → "2026-Q2"
*/
export function dateToPeriod(dateStr: string): string {
const d = new Date(dateStr);
const year = d.getFullYear();
const q = Math.ceil((d.getMonth() + 1) / 3);
return `${year}-Q${q}`;
}
export async function createObjective(data: {
title: string;
ownerId: string;
projectId: string;
startDate: string;
endDate: string;
period?: string;
}) {
const id = uuid();
const now = new Date();
const period = data.period || dateToPeriod(data.endDate);
await db.insert(objectives).values({
id,
title: data.title,
ownerId: data.ownerId,
projectId: data.projectId,
period,
startDate: data.startDate,
endDate: data.endDate,
progress: 0,
createdAt: now,
updatedAt: now,
});
return { id };
}
export async function createKeyResult(objectiveId: string, data: {
title: string;
targetValue: number;
unit: string;
weight: number;
startDate?: string;
endDate?: string;
linkedPlaneCycleId?: string;
linkedPlaneModuleId?: string;
}) {
const objective = await db.query.objectives.findFirst({
where: eq(objectives.id, objectiveId),
});
if (!objective) {
throw new AppError(40403, 'Objective not found', 404);
}
const id = uuid();
const now = new Date();
await db.insert(keyResults).values({
id,
objectiveId,
title: data.title,
targetValue: data.targetValue,
currentValue: 0,
unit: data.unit,
weight: data.weight,
startDate: data.startDate || null,
endDate: data.endDate || null,
linkedPlaneCycleId: data.linkedPlaneCycleId || null,
linkedPlaneModuleId: data.linkedPlaneModuleId || null,
createdAt: now,
updatedAt: now,
});
// 自动更新目标的时间范围(取所有任务的最早开始 ~ 最晚截止)
await recalcObjectiveDates(objectiveId);
return { id };
}
/**
* 根据所有 KR 的日期自动更新 Objective 的 startDate / endDate / period
*/
async function recalcObjectiveDates(objectiveId: string) {
const krs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, objectiveId));
const starts = krs.map(kr => kr.startDate).filter(Boolean) as string[];
const ends = krs.map(kr => kr.endDate).filter(Boolean) as string[];
if (starts.length === 0 && ends.length === 0) return;
const earliest = starts.length > 0 ? starts.sort()[0] : null;
const latest = ends.length > 0 ? ends.sort().reverse()[0] : null;
const period = latest ? dateToPeriod(latest) : undefined;
const updateData: Record<string, any> = { updatedAt: new Date() };
if (earliest) updateData.startDate = earliest;
if (latest) updateData.endDate = latest;
if (period) updateData.period = period;
await db.update(objectives).set(updateData).where(eq(objectives.id, objectiveId));
}
export async function updateKeyResultProgress(krId: string, currentValue: number) {
const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, krId),
});
if (!kr) {
throw new AppError(40404, 'Key result not found', 404);
}
await db.update(keyResults)
.set({ currentValue, updatedAt: new Date() })
.where(eq(keyResults.id, krId));
// Recalculate objective progress (weighted average)
const allKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, kr.objectiveId));
const totalWeight = allKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = allKRs.reduce((sum, k) => {
const val = k.id === krId ? currentValue : (k.currentValue || 0);
const progress = k.targetValue > 0 ? (val / k.targetValue) * 100 : 0;
return sum + progress * (k.weight || 1);
}, 0);
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
await db.update(objectives)
.set({ progress: objectiveProgress, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId));
return {
id: krId,
progress: kr.targetValue > 0 ? Math.round((currentValue / kr.targetValue) * 100) : 0,
objectiveProgress,
};
}
export async function deleteObjective(id: string) {
// Delete all KRs first
await db.delete(keyResults).where(eq(keyResults.objectiveId, id));
await db.delete(objectives).where(eq(objectives.id, id));
}
export async function deleteKeyResult(id: string) {
const kr = await db.query.keyResults.findFirst({
where: eq(keyResults.id, id),
});
if (!kr) {
throw new AppError(40404, 'Key result not found', 404);
}
await db.delete(keyResults).where(eq(keyResults.id, id));
// Recalculate objective progress
const remainingKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, kr.objectiveId));
if (remainingKRs.length === 0) {
await db.update(objectives)
.set({ progress: 0, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId));
} else {
const totalWeight = remainingKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = remainingKRs.reduce((sum, k) => {
const progress = k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0;
return sum + progress * (k.weight || 1);
}, 0);
const objectiveProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
await db.update(objectives)
.set({ progress: objectiveProgress, updatedAt: new Date() })
.where(eq(objectives.id, kr.objectiveId));
}
// 重算目标时间范围
await recalcObjectiveDates(kr.objectiveId);
}
// ── 操作日志辅助 ──
async function addKRLog(krId: string, action: string, detail: string | null, operatorId: string, operatorName: string) {
await db.insert(krLogs).values({
id: uuid(),
krId,
action,
detail,
operatorId,
operatorName,
createdAt: new Date(),
});
}
export async function recalcObjectiveProgress(objectiveId: string) {
const allKRs = await db.select().from(keyResults)
.where(eq(keyResults.objectiveId, objectiveId));
// 只算 active 和 completed 的 KR暂停和取消的不计入
const activeKRs = allKRs.filter(kr => kr.status !== 'paused' && kr.status !== 'cancelled');
const totalWeight = activeKRs.reduce((sum, k) => sum + (k.weight || 1), 0);
const weightedProgress = activeKRs.reduce((sum, k) => {
// completed 状态按 100% 计入
const progress = k.status === 'completed'
? 100
: (k.targetValue > 0 ? ((k.currentValue || 0) / k.targetValue) * 100 : 0);
return sum + progress * (k.weight || 1);
}, 0);
const objProgress = totalWeight > 0 ? Math.round(weightedProgress / totalWeight) : 0;
await db.update(objectives).set({ progress: objProgress, updatedAt: new Date() })
.where(eq(objectives.id, objectiveId));
}
// ── 延期 ──
export async function postponeKR(krId: string, newEndDate: string, reason: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
const oldEndDate = kr.endDate || '未设置';
await db.update(keyResults).set({ endDate: newEndDate, updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'postponed', `截止日期 ${oldEndDate}${newEndDate},原因:${reason}`, operatorId, operatorName);
await recalcObjectiveDates(kr.objectiveId);
return { id: krId, endDate: newEndDate };
}
// ── 暂停 ──
export async function pauseKR(krId: string, reason: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
await db.update(keyResults).set({ status: 'paused', updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'paused', `原因:${reason}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId);
return { id: krId, status: 'paused' };
}
// ── 恢复 ──
export async function resumeKR(krId: string, newEndDate: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
const oldEndDate = kr.endDate || '未设置';
await db.update(keyResults).set({ status: 'active', endDate: newEndDate, updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'resumed', `恢复为进行中,截止日期 ${oldEndDate}${newEndDate}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId);
await recalcObjectiveDates(kr.objectiveId);
return { id: krId, status: 'active', endDate: newEndDate };
}
// ── 取消 ──
export async function cancelKR(krId: string, reason: string, operatorId: string, operatorName: string) {
const kr = await db.query.keyResults.findFirst({ where: eq(keyResults.id, krId) });
if (!kr) throw new AppError(40404, 'Key result not found', 404);
await db.update(keyResults).set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(keyResults.id, krId));
await addKRLog(krId, 'cancelled', `原因:${reason}`, operatorId, operatorName);
await recalcObjectiveProgress(kr.objectiveId);
return { id: krId, status: 'cancelled' };
}
// ── 获取操作日志 ──
export async function getKRLogs(krId: string) {
return await db.select().from(krLogs)
.where(eq(krLogs.krId, krId))
.orderBy(desc(krLogs.createdAt));
}