From b48a7e2049e69f0ffe6849a9b679af6e495442ae Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 18:05:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B0=8F=E5=AE=9D=E9=A2=84=E8=AD=A6):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=97=A5=E6=8A=A5=E8=AF=81=E6=8D=AE=E4=B8=8E?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E8=B6=8B=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/xiaobao-risk-evidence.test.ts | 163 ++++++++++ apps/web/lib/xiaobao-risk-evidence.ts | 340 +++++++++++++++++++++ apps/web/lib/xiaobao-risk-trend.test.ts | 40 +++ apps/web/lib/xiaobao-risk-trend.ts | 78 +++++ apps/web/lib/xiaobao-risk.ts | 38 +-- 5 files changed, 636 insertions(+), 23 deletions(-) create mode 100644 apps/web/lib/xiaobao-risk-evidence.test.ts create mode 100644 apps/web/lib/xiaobao-risk-trend.test.ts diff --git a/apps/web/lib/xiaobao-risk-evidence.test.ts b/apps/web/lib/xiaobao-risk-evidence.test.ts new file mode 100644 index 0000000..52dea07 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-evidence.test.ts @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { Bug } from './bug'; +import type { DevTask } from './dev-task'; +import type { TestCase } from './test-case'; +import type { TaskWorklog } from './task-worklog'; +import type { VersionPlan } from './version-plan'; +import type { WorkActivity } from './work-activity'; +import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from './xiaobao-risk-evidence'; + +function plan(patch: Partial = {}): VersionPlan { + return { + id: 'plan-1', + versionId: 'ver-1', + type: 'product', + title: 'Product plan', + owner: 'pm-1', + startTime: '2026-06-20T01:00:00.000Z', + endTime: '2026-06-25T10:00:00.000Z', + status: 'in_progress', + actualStartAt: '2026-06-20T01:00:00.000Z', + createdAt: '2026-06-20T01:00:00.000Z', + addedBy: 'pm-1', + ...patch, + }; +} + +function devTask(patch: Partial = {}): DevTask { + return { + id: 'dev-1', + taskNo: 'DEV-001', + requirementId: 'req-1', + title: 'Build warning panel', + categoryId: 'frontend', + assigneeId: 'dev-other', + priority: 'P1', + expectedStartAt: '2026-06-20T01:00:00.000Z', + expectedEndAt: '2026-06-26T10:00:00.000Z', + status: 'in_progress', + isBlocked: false, + actualStartAt: '2026-06-20T01:00:00.000Z', + createdBy: 'pm-1', + createdAt: '2026-06-20T01:00:00.000Z', + updatedAt: '2026-06-24T02:00:00.000Z', + ...patch, + }; +} + +function testCase(patch: Partial = {}): TestCase { + return { + id: 'tc-1', + caseNo: 'TC-001', + versionId: 'ver-1', + title: 'Verify warning panel', + categoryId: 'ui-interaction', + priority: 'P2', + assigneeId: 'qa-other', + status: 'running', + startedAt: '2026-06-20T03:00:00.000Z', + createdBy: 'qa-other', + createdAt: '2026-06-20T01:00:00.000Z', + updatedAt: '2026-06-24T02:00:00.000Z', + ...patch, + }; +} + +function bug(patch: Partial = {}): Bug { + return { + id: 'bug-1', + bugNo: 'BUG-001', + versionId: 'ver-1', + testCaseId: 'tc-1', + title: 'Warning count mismatch', + description: 'The count is wrong.', + severity: 'major', + priority: 'P1', + reportedBy: 'qa-other', + assigneeId: 'dev-other', + status: 'open', + createdAt: '2026-06-20T01:00:00.000Z', + updatedAt: '2026-06-24T02:00:00.000Z', + ...patch, + }; +} + +test('buildVersionDailyEvidence groups version activity and detects no activity/no update', () => { + const workItems = buildXiaobaoWorkItems({ + plans: [plan()], + devTasks: [devTask()], + testCases: [testCase()], + bugs: [bug()], + versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }], + requirementVersionMap: new Map([['req-1', 'ver-1']]), + }); + const activities: WorkActivity[] = [ + { + id: 'act-delivery', + actorId: 'dev-other', + date: '2026-06-29', + occurredAt: '2026-06-29T02:00:00.000Z', + sourceType: 'dev_task', + sourceId: 'dev-1', + action: 'dev_task_submitted', + category: 'delivery', + title: 'Build warning panel', + summary: 'Submitted development task', + }, + { + id: 'act-risk', + actorId: 'qa-other', + date: '2026-06-29', + occurredAt: '2026-06-29T03:00:00.000Z', + sourceType: 'bug', + sourceId: 'bug-1', + action: 'bug_blocked', + category: 'risk', + title: 'Warning count mismatch', + summary: 'Blocked by missing data', + }, + ]; + const worklogs: TaskWorklog[] = [ + { + id: 'wl-note', + taskId: 'tc-1', + userId: 'qa-other', + date: '2026-06-29', + hours: 1, + workContent: 'Continued regression testing.', + createdAt: '2026-06-29T04:00:00.000Z', + }, + ]; + + const evidence = buildVersionDailyEvidence({ + versionId: 'ver-1', + workItems, + activities, + worklogs, + now: new Date('2026-06-29T08:00:00.000Z'), + }); + + assert.deepEqual(evidence.todayDeliveries.map((item) => item.id), ['act-delivery']); + assert.deepEqual(evidence.todayRisks.map((item) => item.id), ['act-risk']); + assert.deepEqual(evidence.progressNotes.map((item) => item.id), ['wl-note']); + assert.equal(evidence.recentActivityCount, 3); + assert.equal(evidence.lastActivityAt, '2026-06-29T04:00:00.000Z'); + assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_update' && risk.itemId === 'plan-1')); + assert.ok(evidence.silentRisks?.some((risk) => risk.key === 'no_activity' && risk.itemId === 'plan-1')); +}); + +test('buildXiaobaoWorkItems keeps all version work without current-user filtering', () => { + const items = buildXiaobaoWorkItems({ + plans: [plan({ owner: 'pm-other' })], + devTasks: [devTask({ assigneeId: 'dev-other' })], + testCases: [testCase({ assigneeId: 'qa-other' })], + bugs: [bug({ assigneeId: 'dev-other' })], + versions: [{ id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }], + requirementVersionMap: new Map([['req-1', 'ver-1']]), + }); + + assert.deepEqual(items.map((item) => item.id), ['plan-1', 'dev-1', 'tc-1', 'bug-1']); + assert.deepEqual(items.map((item) => item.versionId), ['ver-1', 'ver-1', 'ver-1', 'ver-1']); +}); diff --git a/apps/web/lib/xiaobao-risk-evidence.ts b/apps/web/lib/xiaobao-risk-evidence.ts index b4e3d9d..9fe0688 100644 --- a/apps/web/lib/xiaobao-risk-evidence.ts +++ b/apps/web/lib/xiaobao-risk-evidence.ts @@ -1,4 +1,11 @@ import type { SilentRisk } from './xiaobao-risk'; +import type { Bug } from './bug'; +import type { DevTask } from './dev-task'; +import type { TaskWorklog } from './task-worklog'; +import type { TestCase } from './test-case'; +import type { VersionPlan } from './version-plan'; +import type { WorkActivity } from './work-activity'; +import type { WorkItem } from './workspace-engine'; export interface EvidenceItem { id: string; @@ -18,3 +25,336 @@ export interface VersionDailyEvidence { lastActivityAt?: string; silentRisks?: SilentRisk[]; } + +export interface BuildVersionDailyEvidenceInput { + versionId: string; + workItems: WorkItem[]; + activities?: WorkActivity[]; + worklogs?: TaskWorklog[]; + now?: Date; +} + +export interface XiaobaoVersionContext { + id: string; + name: string; + productName?: string; + projectName?: string; +} + +export interface BuildXiaobaoWorkItemsInput { + plans?: VersionPlan[]; + devTasks?: DevTask[]; + testCases?: TestCase[]; + bugs?: Bug[]; + versions: XiaobaoVersionContext[]; + requirementVersionMap: Map | Record; +} + +const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']); + +export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] { + const versionMap = new Map(input.versions.map((version) => [version.id, version])); + const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap); + const items: WorkItem[] = []; + + for (const plan of input.plans ?? []) { + const version = versionMap.get(plan.versionId); + items.push({ + id: plan.id, + type: plan.type === 'research' ? 'plan_research' : plan.type === 'product' ? 'plan_product' : 'plan_ui', + title: plan.title, + status: plan.status, + completed: plan.status === 'completed', + productName: version?.productName ?? '-', + projectName: version?.projectName ?? '-', + versionName: version?.name ?? '-', + versionId: plan.versionId, + extra: { + startTime: plan.startTime, + endTime: plan.endTime, + actualStartAt: plan.actualStartAt, + completedAt: plan.completedAt, + }, + raw: plan, + }); + } + + for (const task of input.devTasks ?? []) { + const versionId = requirementVersionMap.get(task.requirementId) ?? ''; + const version = versionMap.get(versionId); + items.push({ + id: task.id, + type: 'devTask', + title: task.title, + status: task.status, + completed: task.status === 'submitted', + productName: version?.productName ?? '-', + projectName: version?.projectName ?? '-', + versionName: version?.name ?? '-', + versionId, + priority: task.priority, + extra: { + taskNo: task.taskNo, + expectedStartAt: task.expectedStartAt, + expectedEndAt: task.expectedEndAt, + actualStartAt: task.actualStartAt, + actualEndAt: task.actualEndAt, + isBlocked: task.isBlocked, + blockReason: task.blockReason, + }, + raw: task, + }); + } + + for (const testCase of input.testCases ?? []) { + const version = versionMap.get(testCase.versionId); + items.push({ + id: testCase.id, + type: 'testCase', + title: testCase.title, + status: testCase.status, + completed: testCase.status === 'passed', + productName: version?.productName ?? '-', + projectName: version?.projectName ?? '-', + versionName: version?.name ?? '-', + versionId: testCase.versionId, + priority: testCase.priority, + extra: { + caseNo: testCase.caseNo, + requirementId: testCase.requirementId, + plannedTestAt: testCase.plannedTestAt, + plannedEndAt: testCase.plannedEndAt, + startedAt: testCase.startedAt, + completedAt: testCase.completedAt, + }, + raw: testCase, + }); + } + + for (const bug of input.bugs ?? []) { + const version = versionMap.get(bug.versionId); + items.push({ + id: bug.id, + type: 'bug', + title: bug.title, + status: bug.status, + completed: bug.status === 'closed' || bug.status === 'rejected', + productName: version?.productName ?? '-', + projectName: version?.projectName ?? '-', + versionName: version?.name ?? '-', + versionId: bug.versionId, + priority: bug.priority, + extra: { + bugNo: bug.bugNo, + severity: bug.severity, + testCaseId: bug.testCaseId, + plannedFixAt: bug.plannedFixAt, + }, + raw: bug, + }); + } + + return items; +} + +export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput): VersionDailyEvidence { + const now = input.now ?? new Date(); + const today = now.toISOString().slice(0, 10); + const versionItems = input.workItems.filter((item) => item.versionId === input.versionId); + const versionItemIds = new Set(versionItems.map((item) => item.id)); + const itemMap = new Map(versionItems.map((item) => [item.id, item])); + const versionActivities = (input.activities ?? []).filter((activity) => versionItemIds.has(activity.sourceId)); + const versionWorklogs = (input.worklogs ?? []).filter((worklog) => versionItemIds.has(worklog.taskId)); + const todayActivities = versionActivities.filter((activity) => activity.date === today); + const todayWorklogs = versionWorklogs.filter((worklog) => worklog.date === today); + const recentSince = now.getTime() - 3 * 86_400_000; + + const recentActivityCount = [ + ...versionActivities.map((activity) => activity.occurredAt), + ...versionWorklogs.map((worklog) => worklog.createdAt), + ].filter((value) => { + const date = parseDate(value); + return Boolean(date && date.getTime() >= recentSince); + }).length; + const lastActivityAt = latestIso([ + ...versionActivities.map((activity) => activity.occurredAt), + ...versionWorklogs.map((worklog) => worklog.createdAt), + ]); + + const touchedTodayIds = new Set([ + ...todayActivities.map((activity) => activity.sourceId), + ...todayWorklogs.map((worklog) => worklog.taskId), + ]); + + return { + todayDeliveries: todayActivities.filter((activity) => activity.category === 'delivery').map(activityToEvidenceItem), + todayProgress: todayActivities.filter((activity) => activity.category === 'progress').map(activityToEvidenceItem), + todayRisks: todayActivities.filter((activity) => activity.category === 'risk').map(activityToEvidenceItem), + progressNotes: [ + ...todayActivities.filter((activity) => activity.category === 'note').map(activityToEvidenceItem), + ...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))), + ].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)), + needsProgressItems: versionItems + .filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now)) + .map(itemToEvidenceItem), + recentActivityCount, + lastActivityAt, + silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now), + }; +} + +function normalizeRequirementVersionMap(input: Map | Record): Map { + return input instanceof Map ? input : new Map(Object.entries(input)); +} + +function activityToEvidenceItem(activity: WorkActivity): EvidenceItem { + return { + id: activity.id, + title: activity.title, + summary: activity.summary, + occurredAt: activity.occurredAt, + actorId: activity.actorId, + }; +} + +function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem { + return { + id: worklog.id, + title: item?.title ?? worklog.taskId, + summary: worklog.workContent, + occurredAt: worklog.createdAt, + actorId: worklog.userId, + }; +} + +function itemToEvidenceItem(item: WorkItem): EvidenceItem { + return { + id: item.id, + title: item.title, + summary: `${item.status}`, + occurredAt: getLatestItemTouchAt(item) ?? '', + }; +} + +function buildSilentRisks( + items: WorkItem[], + activities: WorkActivity[], + worklogs: TaskWorklog[], + now: Date, +): SilentRisk[] { + const risks: SilentRisk[] = []; + for (const item of items) { + if (item.completed) continue; + const latestUpdateAt = getLatestItemTouchAt(item); + const latestEvidenceAt = latestIso([ + ...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt), + ...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt), + ]); + const latestTouchAt = latestIso([latestUpdateAt, latestEvidenceAt]); + const evidenceDays = daysSince(latestEvidenceAt, now); + const updateDays = daysSince(latestUpdateAt, now); + const touchDays = daysSince(latestTouchAt, now); + + if (updateDays >= 5) { + risks.push(makeSilentRisk('no_update', item, `No status update for ${Math.floor(updateDays)} days.`)); + } + if (evidenceDays >= 8) { + risks.push(makeSilentRisk('no_report', item, `No progress report for ${Math.floor(evidenceDays)} days.`)); + } + if (evidenceDays >= 4) { + risks.push(makeSilentRisk('no_activity', item, `No work activity for ${Math.floor(evidenceDays)} days.`)); + } + if (isActiveUnfinished(item) && touchDays >= 3) { + risks.push(makeSilentRisk('unhandled', item, `Active work has been untouched for ${Math.floor(touchDays)} days.`)); + } + } + return risks; +} + +function makeSilentRisk(key: string, item: WorkItem, detail: string): SilentRisk { + return { + key, + title: item.title, + detail, + itemId: item.id, + itemType: workItemToSilentItemType(item), + }; +} + +function shouldNeedProgress( + item: WorkItem, + touchedTodayIds: Set, + activities: WorkActivity[], + worklogs: TaskWorklog[], + now: Date, +): boolean { + if (item.completed || touchedTodayIds.has(item.id) || !isActiveUnfinished(item)) return false; + const latestTouchAt = latestIso([ + getLatestItemTouchAt(item), + ...activities.filter((activity) => activity.sourceId === item.id).map((activity) => activity.occurredAt), + ...worklogs.filter((worklog) => worklog.taskId === item.id).map((worklog) => worklog.createdAt), + ]); + return daysSince(latestTouchAt, now) >= 3; +} + +function isActiveUnfinished(item: WorkItem): boolean { + return !item.completed && ACTIVE_STATUSES.has(item.status); +} + +function workItemToSilentItemType(item: WorkItem): SilentRisk['itemType'] { + if (item.type === 'devTask') return 'dev_task'; + if (item.type === 'testCase') return 'test_case'; + if (item.type === 'bug') return 'bug'; + return 'version'; +} + +function getLatestItemTouchAt(item: WorkItem): string | undefined { + const raw = (item.raw ?? {}) as unknown as Record; + const extra = item.extra ?? {}; + return latestIso([ + asString(raw.updatedAt), + asString(raw.completedAt), + asString(raw.actualEndAt), + asString(raw.resolvedAt), + asString(raw.closedAt), + asString(raw.actualStartAt), + asString(raw.startedAt), + asString(raw.createdAt), + asString(extra.completedAt), + asString(extra.actualEndAt), + asString(extra.actualStartAt), + asString(extra.startedAt), + asString(extra.startTime), + ]); +} + +function latestIso(values: Array): string | undefined { + let latest: string | undefined; + let latestTime = Number.NEGATIVE_INFINITY; + for (const value of values) { + const date = parseDate(value); + if (!date) continue; + const time = date.getTime(); + if (time > latestTime) { + latest = value; + latestTime = time; + } + } + return latest; +} + +function daysSince(value: string | undefined, now: Date): number { + const date = parseDate(value); + if (!date) return Number.POSITIVE_INFINITY; + return Math.max(0, (now.getTime() - date.getTime()) / 86_400_000); +} + +function parseDate(value: string | undefined): Date | undefined { + if (!value) return undefined; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/apps/web/lib/xiaobao-risk-trend.test.ts b/apps/web/lib/xiaobao-risk-trend.test.ts new file mode 100644 index 0000000..2370018 --- /dev/null +++ b/apps/web/lib/xiaobao-risk-trend.test.ts @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildRiskSignature, summarizeRiskTrend } from './xiaobao-risk-trend'; +import type { XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; + +function snapshot(patch: Partial): XiaobaoRiskSnapshot { + return { + versionId: 'ver-1', + date: '2026-06-27', + riskScore: 38, + riskLevel: 'attention', + openBugCount: 0, + failedTestCount: 0, + blockedCount: 0, + silentRiskCount: 0, + confidence: 80, + createdAt: '2026-06-27T01:00:00.000Z', + ...patch, + }; +} + +test('summarizeRiskTrend detects continuous rising risk across three snapshots', () => { + const trend = summarizeRiskTrend([ + snapshot({ riskScore: 71, createdAt: '2026-06-29T01:00:00.000Z', date: '2026-06-29' }), + snapshot({ riskScore: 38, createdAt: '2026-06-27T01:00:00.000Z', date: '2026-06-27' }), + snapshot({ riskScore: 52, createdAt: '2026-06-28T01:00:00.000Z', date: '2026-06-28' }), + ]); + + assert.equal(trend.direction, 'up'); + assert.equal(trend.delta, 33); + assert.equal(trend.pattern, 'continuous_rising'); +}); + +test('buildRiskSignature changes when score and bug counts change', () => { + const base = buildRiskSignature(snapshot({ riskScore: 52, openBugCount: 1 })); + const changed = buildRiskSignature(snapshot({ riskScore: 71, openBugCount: 2 })); + + assert.notEqual(base, changed); +}); diff --git a/apps/web/lib/xiaobao-risk-trend.ts b/apps/web/lib/xiaobao-risk-trend.ts index 2843cc4..c3249e1 100644 --- a/apps/web/lib/xiaobao-risk-trend.ts +++ b/apps/web/lib/xiaobao-risk-trend.ts @@ -13,3 +13,81 @@ export interface XiaobaoRiskSnapshot { confidence: number; createdAt: string; } + +export interface RiskTrendSummary { + direction: 'up' | 'down' | 'flat' | 'unknown'; + delta: number; + summary: string; + pattern: 'continuous_rising' | 'continuous_falling' | 'score_delta' | 'stable' | 'unknown'; +} + +export function summarizeRiskTrend(snapshots: XiaobaoRiskSnapshot[]): RiskTrendSummary { + const sorted = [...snapshots].sort((a, b) => getSnapshotTime(a) - getSnapshotTime(b)); + if (sorted.length < 2) { + return { direction: 'unknown', delta: 0, summary: 'No historical risk snapshots yet.', pattern: 'unknown' }; + } + + const first = sorted[0]; + const latest = sorted[sorted.length - 1]; + const delta = clampScore(latest.riskScore) - clampScore(first.riskScore); + const recentThree = sorted.slice(-3); + + if (recentThree.length === 3) { + const [a, b, c] = recentThree.map((snapshot) => clampScore(snapshot.riskScore)); + if (a < b && b < c) { + return { + direction: 'up', + delta, + summary: `Risk rose continuously from ${a} to ${c}.`, + pattern: 'continuous_rising', + }; + } + if (a > b && b > c) { + return { + direction: 'down', + delta, + summary: `Risk fell continuously from ${a} to ${c}.`, + pattern: 'continuous_falling', + }; + } + } + + if (delta >= 10) { + return { direction: 'up', delta, summary: `Risk rose by ${delta} points.`, pattern: 'score_delta' }; + } + if (delta <= -10) { + return { direction: 'down', delta, summary: `Risk fell by ${Math.abs(delta)} points.`, pattern: 'score_delta' }; + } + return { direction: 'flat', delta, summary: 'Risk is stable.', pattern: 'stable' }; +} + +export function summarizeRiskTrendWithCurrent( + snapshots: XiaobaoRiskSnapshot[] = [], + current: XiaobaoRiskSnapshot, +): RiskTrendSummary { + return summarizeRiskTrend([...snapshots, current]); +} + +export function buildRiskSignature(snapshot: XiaobaoRiskSnapshot): string { + return [ + snapshot.versionId, + snapshot.date, + clampScore(snapshot.riskScore), + snapshot.riskLevel, + snapshot.forecastReleaseDate ?? '', + snapshot.openBugCount, + snapshot.failedTestCount, + snapshot.blockedCount, + snapshot.silentRiskCount, + clampScore(snapshot.confidence), + ].join('|'); +} + +function getSnapshotTime(snapshot: XiaobaoRiskSnapshot): number { + const date = new Date(snapshot.createdAt || snapshot.date).getTime(); + return Number.isFinite(date) ? date : 0; +} + +function clampScore(score: number): number { + return Math.max(0, Math.min(100, Math.round(score))); +} diff --git a/apps/web/lib/xiaobao-risk.ts b/apps/web/lib/xiaobao-risk.ts index b676c64..4737997 100644 --- a/apps/web/lib/xiaobao-risk.ts +++ b/apps/web/lib/xiaobao-risk.ts @@ -4,6 +4,7 @@ import { STATUS_PROGRESS, getEstimateHours } from './dev-task'; import type { TestCase } from './test-case'; import { getTestCaseEstimateHours } from './test-case'; import type { VersionDailyEvidence } from './xiaobao-risk-evidence'; +import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend'; import { WORK_HOURS, addWorkHours } from './work-hours'; export type XiaobaoRiskLevel = 'on_track' | 'attention' | 'at_risk' | 'likely_delayed' | 'blocked'; @@ -72,7 +73,7 @@ export interface CalcXiaobaoVersionRiskInput { testCases?: TestCase[]; bugs?: Bug[]; now?: Date; - snapshots?: Array<{ riskScore: number; createdAt?: string; date?: string }>; + snapshots?: XiaobaoRiskSnapshot[]; silentRisks?: SilentRisk[]; dailyEvidence?: VersionDailyEvidence; recentActivityCount?: number; @@ -197,7 +198,19 @@ export function calcXiaobaoVersionRisk(input: CalcXiaobaoVersionRiskInput): Xiao 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 ?? []); + const trend = summarizeRiskTrendWithCurrent(input.snapshots ?? [], { + versionId: input.version.id, + date: now.toISOString().slice(0, 10), + riskScore, + riskLevel, + forecastReleaseDate, + openBugCount: signals.criticalBugCount, + failedTestCount: signals.failedTestCount, + blockedCount: signals.blockedCount, + silentRiskCount: signals.silentRiskCount, + confidence, + createdAt: now.toISOString(), + }); return { versionId: input.version.id, @@ -280,27 +293,6 @@ function hasEstimate(item: DevTask | TestCase | Bug): boolean { || (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';