Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import type { VersionWithContext } from './derive';
|
|
import type { V22XiaobaoWarningSummary } from './v22-api';
|
|
import type { XiaobaoRiskInsightCacheItem } from './xiaobao-risk-cache';
|
|
import type {
|
|
RiskReason,
|
|
SilentRisk,
|
|
XiaobaoConfidenceLevel,
|
|
XiaobaoRiskLevel,
|
|
XiaobaoRiskSignals,
|
|
XiaobaoVersionRisk,
|
|
} from './xiaobao-risk';
|
|
import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence';
|
|
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
|
|
|
interface BuildXiaobaoRisksFromV22SummariesInput {
|
|
summaries: V22XiaobaoWarningSummary[];
|
|
versions: VersionWithContext[];
|
|
snapshots?: XiaobaoRiskSnapshot[];
|
|
now?: Date;
|
|
}
|
|
|
|
export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed';
|
|
|
|
const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']);
|
|
const REASON_SEVERITIES = new Set<RiskReason['severity']>(['info', 'warning', 'danger']);
|
|
const SILENT_RISK_ITEM_TYPES = new Set<NonNullable<SilentRisk['itemType']>>(['dev_task', 'test_case', 'bug', 'version']);
|
|
|
|
export function shouldLoadXiaobaoStoreFallback(state: V22XiaobaoSummaryLoadState): boolean {
|
|
return state === 'empty' || state === 'failed';
|
|
}
|
|
|
|
export function filterV22XiaobaoSummariesForVisibleVersions(
|
|
summaries: V22XiaobaoWarningSummary[],
|
|
versions: VersionWithContext[],
|
|
): V22XiaobaoWarningSummary[] {
|
|
const visibleVersionIds = new Set(versions.map((version) => version.id));
|
|
if (visibleVersionIds.size === 0) return [];
|
|
return summaries.filter((summary) => visibleVersionIds.has(summary.versionId));
|
|
}
|
|
|
|
export function collectV22XiaobaoLatestInsights(
|
|
summaries: V22XiaobaoWarningSummary[],
|
|
): XiaobaoRiskInsightCacheItem[] {
|
|
return summaries
|
|
.map((summary) => summary.latestInsight)
|
|
.filter((item): item is XiaobaoRiskInsightCacheItem => Boolean(item));
|
|
}
|
|
|
|
export function mergeV22XiaobaoLatestInsights(
|
|
relationInsights: XiaobaoRiskInsightCacheItem[],
|
|
legacyInsights: XiaobaoRiskInsightCacheItem[],
|
|
): XiaobaoRiskInsightCacheItem[] {
|
|
const relationKeys = new Set(relationInsights.map((item) => insightKey(item)));
|
|
return [
|
|
...relationInsights,
|
|
...legacyInsights.filter((item) => !relationKeys.has(insightKey(item))),
|
|
];
|
|
}
|
|
|
|
export function buildXiaobaoRisksFromV22Summaries(
|
|
input: BuildXiaobaoRisksFromV22SummariesInput,
|
|
): XiaobaoVersionRisk[] {
|
|
const now = input.now ?? new Date();
|
|
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
|
|
|
|
return input.summaries
|
|
.map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now))
|
|
.sort((a, b) => b.riskScore - a.riskScore);
|
|
}
|
|
|
|
function mapSummaryToRisk(
|
|
row: V22XiaobaoWarningSummary,
|
|
version: VersionWithContext | undefined,
|
|
snapshots: XiaobaoRiskSnapshot[],
|
|
now: Date,
|
|
): XiaobaoVersionRisk {
|
|
const payload = getRiskPayload(row.summary);
|
|
const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention';
|
|
const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore);
|
|
const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence);
|
|
const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate;
|
|
const expectedReleaseDate =
|
|
readString(payload.expectedReleaseDate)
|
|
?? version?.expectedReleaseDate
|
|
?? version?.releaseDate
|
|
?? null;
|
|
const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate);
|
|
const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0;
|
|
const dailyEvidence = readDailyEvidence(payload.dailyEvidence);
|
|
const silentRisks = readSilentRisks(payload.silentRisks);
|
|
const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now);
|
|
const reasons = readReasons(payload.reasons);
|
|
const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString();
|
|
const currentSnapshot = buildCurrentSnapshot({
|
|
payload,
|
|
row,
|
|
riskLevel,
|
|
riskScore,
|
|
confidence,
|
|
forecastReleaseDate,
|
|
signals,
|
|
sourceTime,
|
|
now,
|
|
});
|
|
const trend = readTrend(payload.trend)
|
|
?? summarizeRiskTrendWithCurrent(
|
|
snapshots.filter((snapshot) => snapshot.versionId === row.versionId),
|
|
currentSnapshot,
|
|
);
|
|
|
|
return {
|
|
versionId: row.versionId,
|
|
versionName: readString(payload.versionName) ?? version?.name ?? row.versionId,
|
|
productId: readString(payload.productId) ?? version?.productId,
|
|
productName: readString(payload.productName) ?? version?.productName,
|
|
projectId: readString(payload.projectId) ?? version?.projectId,
|
|
projectName: readString(payload.projectName) ?? version?.projectName,
|
|
riskScore,
|
|
riskLevel,
|
|
expectedReleaseDate,
|
|
confidence,
|
|
confidenceLevel: getConfidenceLevel(confidence),
|
|
forecastReleaseDate,
|
|
delayDays,
|
|
remainingWorkHours,
|
|
reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore),
|
|
silentRisks,
|
|
dailyEvidence,
|
|
signals,
|
|
currentSnapshot,
|
|
trend,
|
|
};
|
|
}
|
|
|
|
function getRiskPayload(summary: unknown): Record<string, unknown> {
|
|
const root = readRecord(summary);
|
|
const nested = readRecord(root.risk);
|
|
return Object.keys(nested).length > 0 ? nested : root;
|
|
}
|
|
|
|
function insightKey(item: XiaobaoRiskInsightCacheItem): string {
|
|
return `${item.versionId}::${item.riskSignature}`;
|
|
}
|
|
|
|
function buildCurrentSnapshot(input: {
|
|
payload: Record<string, unknown>;
|
|
row: V22XiaobaoWarningSummary;
|
|
riskLevel: XiaobaoRiskLevel;
|
|
riskScore: number;
|
|
confidence: number;
|
|
forecastReleaseDate?: string;
|
|
signals: XiaobaoRiskSignals;
|
|
sourceTime: string;
|
|
now: Date;
|
|
}): XiaobaoRiskSnapshot {
|
|
const snapshot = readRecord(input.payload.currentSnapshot);
|
|
const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString();
|
|
return {
|
|
versionId: input.row.versionId,
|
|
date: readString(snapshot.date) ?? createdAt.slice(0, 10),
|
|
riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore),
|
|
riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel,
|
|
forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate,
|
|
openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount,
|
|
criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount,
|
|
failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount,
|
|
blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount,
|
|
silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount,
|
|
confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence),
|
|
createdAt,
|
|
};
|
|
}
|
|
|
|
function readSignals(
|
|
value: unknown,
|
|
dailyEvidence: VersionDailyEvidence | undefined,
|
|
silentRisks: SilentRisk[],
|
|
expectedReleaseDate: string | null,
|
|
now: Date,
|
|
): XiaobaoRiskSignals {
|
|
const row = readRecord(value);
|
|
return {
|
|
unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0,
|
|
openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0,
|
|
criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0,
|
|
failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0,
|
|
blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0,
|
|
silentRiskCount:
|
|
nonNegativeInteger(readNumber(row.silentRiskCount))
|
|
?? dailyEvidence?.silentRisks?.length
|
|
?? silentRisks.length,
|
|
daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now),
|
|
};
|
|
}
|
|
|
|
function readReasons(value: unknown): RiskReason[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map((item) => {
|
|
const row = readRecord(item);
|
|
const key = readString(row.key);
|
|
const title = readString(row.title);
|
|
const detail = readString(row.detail);
|
|
if (!key || !title || !detail) return undefined;
|
|
return {
|
|
key,
|
|
title,
|
|
detail,
|
|
severity: toReasonSeverity(readString(row.severity)),
|
|
count: nonNegativeInteger(readNumber(row.count)),
|
|
};
|
|
}).filter(isDefined);
|
|
}
|
|
|
|
function readSilentRisks(value: unknown): SilentRisk[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map((item) => {
|
|
const row = readRecord(item);
|
|
const key = readString(row.key);
|
|
const title = readString(row.title);
|
|
const detail = readString(row.detail);
|
|
if (!key || !title || !detail) return undefined;
|
|
return {
|
|
key,
|
|
title,
|
|
detail,
|
|
itemId: readString(row.itemId),
|
|
itemType: toSilentRiskItemType(readString(row.itemType)),
|
|
};
|
|
}).filter(isDefined);
|
|
}
|
|
|
|
function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined {
|
|
const row = readRecord(value);
|
|
if (Object.keys(row).length === 0) return undefined;
|
|
return {
|
|
todayDeliveries: readEvidenceItems(row.todayDeliveries),
|
|
todayProgress: readEvidenceItems(row.todayProgress),
|
|
todayCreations: readEvidenceItems(row.todayCreations),
|
|
todayRisks: readEvidenceItems(row.todayRisks),
|
|
progressNotes: readEvidenceItems(row.progressNotes),
|
|
needsProgressItems: readEvidenceItems(row.needsProgressItems),
|
|
recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0,
|
|
totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0,
|
|
todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0,
|
|
lastActivityAt: readString(row.lastActivityAt),
|
|
silentRisks: readSilentRisks(row.silentRisks),
|
|
};
|
|
}
|
|
|
|
function readEvidenceItems(value: unknown): EvidenceItem[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.map((item, index) => {
|
|
const row = readRecord(item);
|
|
const summary = readString(row.summary);
|
|
if (!summary) return undefined;
|
|
return {
|
|
id: readString(row.id) ?? `v22-evidence-${index}`,
|
|
title: readString(row.title) ?? summary,
|
|
summary,
|
|
occurredAt: readString(row.occurredAt) ?? '',
|
|
actorId: readString(row.actorId),
|
|
};
|
|
}).filter(isDefined);
|
|
}
|
|
|
|
function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined {
|
|
const row = readRecord(value);
|
|
const direction = readString(row.direction);
|
|
const summary = readString(row.summary);
|
|
if (!direction || !summary) return undefined;
|
|
if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined;
|
|
const pattern = readString(row.pattern);
|
|
return {
|
|
direction,
|
|
delta: readNumber(row.delta) ?? 0,
|
|
summary,
|
|
pattern: pattern === 'continuous_rising'
|
|
|| pattern === 'continuous_falling'
|
|
|| pattern === 'score_delta'
|
|
|| pattern === 'stable'
|
|
|| pattern === 'unknown'
|
|
? pattern
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] {
|
|
if (riskLevel === 'on_track') return [];
|
|
return [{
|
|
key: 'precomputed_risk',
|
|
title: 'Precomputed risk',
|
|
detail: `V2.2 precomputed summary reports risk score ${riskScore}.`,
|
|
severity: riskScore >= 75 ? 'danger' : 'warning',
|
|
}];
|
|
}
|
|
|
|
function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined {
|
|
return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined;
|
|
}
|
|
|
|
function toReasonSeverity(value: string | undefined): RiskReason['severity'] {
|
|
return value && REASON_SEVERITIES.has(value as RiskReason['severity'])
|
|
? value as RiskReason['severity']
|
|
: 'warning';
|
|
}
|
|
|
|
function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined {
|
|
return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable<SilentRisk['itemType']>)
|
|
? value as NonNullable<SilentRisk['itemType']>
|
|
: undefined;
|
|
}
|
|
|
|
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
|
|
if (confidence >= 75) return 'high';
|
|
if (confidence >= 50) return 'medium';
|
|
return 'low';
|
|
}
|
|
|
|
function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number {
|
|
const expected = parseDate(expectedReleaseDate);
|
|
const forecast = parseDate(forecastReleaseDate);
|
|
if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0;
|
|
return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10;
|
|
}
|
|
|
|
function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined {
|
|
const expected = parseDate(expectedReleaseDate);
|
|
if (!expected) return undefined;
|
|
return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10;
|
|
}
|
|
|
|
function parseDate(value: string | null | undefined): Date | undefined {
|
|
if (!value) return undefined;
|
|
const date = new Date(value);
|
|
return Number.isFinite(date.getTime()) ? date : undefined;
|
|
}
|
|
|
|
function readRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
function readNumber(value: unknown): number | undefined {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function nonNegativeNumber(value: number | undefined): number | undefined {
|
|
return value !== undefined && value >= 0 ? value : undefined;
|
|
}
|
|
|
|
function nonNegativeInteger(value: number | undefined): number | undefined {
|
|
return value !== undefined && value >= 0 ? Math.round(value) : undefined;
|
|
}
|
|
|
|
function clampScore(value: number): number {
|
|
return Math.max(0, Math.min(100, Math.round(value)));
|
|
}
|
|
|
|
function isDefined<T>(value: T | undefined): value is T {
|
|
return value !== undefined;
|
|
}
|