feat(小宝预警): 增加版本风险预测引擎

This commit is contained in:
Script Generator
2026-06-29 17:35:45 +08:00
parent ea360c74d6
commit 7b3638c915
4 changed files with 453 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
import type { Bug } from './bug';
import type { DevTask } from './dev-task';
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
import type { TestCase } from './test-case';
import { getTestCaseEstimateHours } from './test-case';
import { WORK_HOURS, addWorkHours } from './work-hours';
export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked';
export type XiaobaoConfidenceLevel = 'high' | 'medium' | 'low';
export interface XiaobaoVersionRef {
id: string;
name: string;
status?: string;
productName?: string;
projectName?: string;
expectedReleaseDate?: string;
members?: Array<{ id?: string; name: string; role?: string }>;
}
export interface RiskReason {
key: string;
title: string;
detail: string;
severity: 'info' | 'warning' | 'danger';
count?: number;
}
export interface SilentRisk {
key: string;
title: string;
detail: string;
itemId?: string;
itemType?: 'dev_task' | 'test_case' | 'bug' | 'version';
}
export interface XiaobaoRiskSignals {
unfinishedCount: number;
openBugCount: number;
criticalBugCount: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
daysToExpectedRelease?: number;
}
export interface XiaobaoVersionRisk {
versionId: string;
riskScore: number;
riskLevel: XiaobaoRiskLevel;
confidence: number;
confidenceLevel: XiaobaoConfidenceLevel;
forecastReleaseDate?: string;
delayDays: number;
remainingWorkHours: number;
reasons: RiskReason[];
silentRisks: SilentRisk[];
signals: XiaobaoRiskSignals;
trend: {
direction: 'up' | 'down' | 'flat' | 'unknown';
delta: number;
summary: string;
};
}
export interface CalcXiaobaoVersionRiskInput {
version: XiaobaoVersionRef;
devTasks?: DevTask[];
testCases?: TestCase[];
bugs?: Bug[];
now?: Date;
snapshots?: Array<{ riskScore: number; createdAt?: string; date?: string }>;
silentRisks?: SilentRisk[];
recentActivityCount?: number;
lastActivityAt?: string;
}
const OPEN_BUG_STATUSES = new Set<Bug['status']>(['open', 'fixing', 'fixed', 'verifying']);
const TEST_DONE_STATUSES = new Set<TestCase['status']>(['passed']);
export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): XiaobaoVersionRisk {
const now = input.now ?? new Date();
const devTasks = input.devTasks ?? [];
const testCases = input.testCases ?? [];
const bugs = input.bugs ?? [];
const silentRisks = input.silentRisks ?? [];
const remainingDevHours = devTasks.reduce((sum, task) => {
const estimate = getEstimateHours(task);
const progress = STATUS_PROGRESS[task.status] ?? 0;
return sum + estimate * Math.max(0, 100 - progress) / 100;
}, 0);
const remainingTestHours = testCases.reduce((sum, tc) => {
if (TEST_DONE_STATUSES.has(tc.status)) return sum;
return sum + getTestCaseEstimateHours(tc);
}, 0);
const openBugs = bugs.filter((bug) => OPEN_BUG_STATUSES.has(bug.status));
const remainingBugHours = openBugs.reduce((sum, bug) => sum + getBugEstimateHours(bug), 0);
const remainingWorkHours = roundHours(remainingDevHours + remainingTestHours + remainingBugHours);
const forecastReleaseDate = remainingWorkHours > 0 ? addWorkHours(now.toISOString(), remainingWorkHours) : undefined;
const expectedRelease = parseDate(input.version.expectedReleaseDate);
const forecast = parseDate(forecastReleaseDate);
const delayDays = expectedRelease && forecast && forecast.getTime() > expectedRelease.getTime()
? roundDays((forecast.getTime() - expectedRelease.getTime()) / 86_400_000)
: 0;
const daysToExpectedRelease = expectedRelease
? roundDays((expectedRelease.getTime() - now.getTime()) / 86_400_000)
: undefined;
const criticalBugCount = openBugs.filter((bug) => bug.severity === 'critical' || bug.priority === 'P1').length;
const failedTestCount = testCases.filter((tc) => tc.status === 'failed').length;
const blockedDevCount = devTasks.filter((task) => task.isBlocked).length;
const blockedTestCount = testCases.filter((tc) => tc.status === 'blocked').length;
const blockedCount = blockedDevCount + blockedTestCount;
const unfinishedCount = devTasks.filter((task) => task.status !== 'submitted').length
+ testCases.filter((tc) => tc.status !== 'passed').length
+ openBugs.length;
const reasons: RiskReason[] = [];
if (remainingWorkHours > 0) {
reasons.push({
key: 'remaining_work',
title: '剩余工作量',
detail: `预计还剩 ${remainingWorkHours}h 工作量。`,
severity: delayDays > 0 ? 'danger' : 'warning',
});
}
if (delayDays > 0) {
reasons.push({
key: 'forecast_delay',
title: '预测延期',
detail: `预测发布时间晚于计划约 ${delayDays} 天。`,
severity: 'danger',
});
}
if (criticalBugCount > 0) {
reasons.push({
key: 'critical_bug',
title: '关键缺陷',
detail: `仍有 ${criticalBugCount} 个 P1 或致命 Bug 未关闭。`,
severity: 'danger',
count: criticalBugCount,
});
}
if (blockedCount > 0) {
reasons.push({
key: 'blocked_work',
title: '阻塞工作',
detail: `仍有 ${blockedCount} 个开发或测试项处于阻塞。`,
severity: 'danger',
count: blockedCount,
});
}
if (failedTestCount > 0) {
reasons.push({
key: 'failed_test',
title: '失败用例',
detail: `仍有 ${failedTestCount} 个测试用例未通过。`,
severity: 'warning',
count: failedTestCount,
});
}
const signals: XiaobaoRiskSignals = {
unfinishedCount,
openBugCount: openBugs.length,
criticalBugCount,
failedTestCount,
blockedCount,
silentRiskCount: silentRisks.length,
daysToExpectedRelease,
};
const riskScore = calcRiskScore({
delayDays,
remainingWorkHours,
criticalBugCount,
failedTestCount,
blockedCount,
silentRiskCount: silentRisks.length,
daysToExpectedRelease,
});
const hasBlockingRisk = criticalBugCount > 0 || blockedCount > 0;
const riskLevel = getRiskLevel(riskScore, delayDays, hasBlockingRisk);
const confidence = calcConfidence(input, devTasks, testCases, bugs);
const trend = calcTrend(riskScore, input.snapshots ?? []);
return {
versionId: input.version.id,
riskScore,
riskLevel,
confidence,
confidenceLevel: getConfidenceLevel(confidence),
forecastReleaseDate,
delayDays,
remainingWorkHours,
reasons,
silentRisks,
signals,
trend,
};
}
function getBugEstimateHours(bug: Bug): number {
if (typeof bug.estimateHours === 'number' && bug.estimateHours > 0) return roundHours(bug.estimateHours);
if (typeof bug.aiEstimateHours === 'number' && bug.aiEstimateHours > 0) return roundHours(bug.aiEstimateHours);
if (bug.severity === 'critical') return 16;
if (bug.severity === 'major') return 8;
return 4;
}
function calcRiskScore(input: {
delayDays: number;
remainingWorkHours: number;
criticalBugCount: number;
failedTestCount: number;
blockedCount: number;
silentRiskCount: number;
daysToExpectedRelease?: number;
}): number {
let score = 0;
if (input.delayDays > 0) score += 70 + Math.min(15, input.delayDays * 3);
if (input.daysToExpectedRelease !== undefined && input.daysToExpectedRelease <= 2 && input.remainingWorkHours > 0) {
score += Math.min(20, input.remainingWorkHours / WORK_HOURS.hoursPerDay * 4);
}
score += input.criticalBugCount * 25;
score += input.blockedCount * 22;
score += input.failedTestCount * 12;
score += input.silentRiskCount * 8;
if (input.remainingWorkHours > 0 && input.delayDays === 0) {
score += Math.min(35, input.remainingWorkHours / WORK_HOURS.hoursPerDay * 5);
}
return clampScore(score);
}
function getRiskLevel(score: number, delayDays: number, hasBlockingRisk: boolean): XiaobaoRiskLevel {
if (hasBlockingRisk) return 'blocked';
if (delayDays > 0 || score >= 75) return 'likely_delayed';
if (score >= 55) return 'at_risk';
if (score >= 30) return 'attention';
return 'on_track';
}
function calcConfidence(
input: CalcXiaobaoVersionRiskInput,
devTasks: DevTask[],
testCases: TestCase[],
bugs: Bug[],
): number {
let confidence = 100;
if (!input.version.expectedReleaseDate) confidence -= 20;
const workItems = [...devTasks, ...testCases, ...bugs];
const missingEstimateCount = workItems.filter((item) => !hasEstimate(item)).length;
if (missingEstimateCount > 0) confidence -= Math.min(25, missingEstimateCount * 5);
if (testCases.length === 0) confidence -= 15;
if (!input.version.members || input.version.members.length === 0) confidence -= 10;
if (input.recentActivityCount === 0 || input.lastActivityAt === undefined) confidence -= 10;
if ((input.snapshots?.length ?? 0) < 2) confidence -= 10;
return clampScore(confidence);
}
function hasEstimate(item: DevTask | TestCase | Bug): boolean {
return (typeof item.estimateHours === 'number' && item.estimateHours > 0)
|| (typeof item.aiEstimateHours === 'number' && item.aiEstimateHours > 0);
}
function calcTrend(
currentScore: number,
snapshots: Array<{ riskScore: number; createdAt?: string; date?: string }>,
): XiaobaoVersionRisk['trend'] {
if (snapshots.length === 0) {
return { direction: 'unknown', delta: 0, summary: '暂无历史快照。' };
}
const latest = [...snapshots].sort((a, b) => {
const aTime = parseDate(a.createdAt ?? a.date)?.getTime() ?? 0;
const bTime = parseDate(b.createdAt ?? b.date)?.getTime() ?? 0;
return bTime - aTime;
})[0];
const delta = clampScore(currentScore) - clampScore(latest.riskScore);
const direction = Math.abs(delta) < 5 ? 'flat' : delta > 0 ? 'up' : 'down';
const summary = direction === 'flat'
? '风险基本持平。'
: direction === 'up'
? `风险上升 ${delta} 分。`
: `风险下降 ${Math.abs(delta)} 分。`;
return { direction, delta, summary };
}
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
if (confidence >= 75) return 'high';
if (confidence >= 50) return 'medium';
return 'low';
}
function parseDate(iso?: string): Date | undefined {
if (!iso) return undefined;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? undefined : date;
}
function roundHours(hours: number): number {
return Math.round(hours * 2) / 2;
}
function roundDays(days: number): number {
return Math.round(days * 10) / 10;
}
function clampScore(score: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}