313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
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)));
|
|
}
|