feat(小宝预警): 同步今日日报证据

This commit is contained in:
Script Generator
2026-06-30 14:43:06 +08:00
parent 52a88626d4
commit 1daa2f7ec4
10 changed files with 413 additions and 13 deletions

View File

@@ -4,8 +4,9 @@ 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 { WorkActivity, WorkActivityCategory } from './work-activity';
import type { WorkItem } from './workspace-engine';
import { calcActualElapsedHours } from './work-hours';
export interface EvidenceItem {
id: string;
@@ -18,10 +19,13 @@ export interface EvidenceItem {
export interface VersionDailyEvidence {
todayDeliveries: EvidenceItem[];
todayProgress: EvidenceItem[];
todayCreations: EvidenceItem[];
todayRisks: EvidenceItem[];
progressNotes: EvidenceItem[];
needsProgressItems: EvidenceItem[];
recentActivityCount: number;
totalActivityCount: number;
todayActualHours: number;
lastActivityAt?: string;
silentRisks?: SilentRisk[];
}
@@ -52,6 +56,13 @@ export interface BuildXiaobaoWorkItemsInput {
const ACTIVE_STATUSES = new Set(['in_progress', 'testing', 'running', 'open', 'fixing', 'fixed', 'verifying']);
type EvidenceDraft = EvidenceItem & {
sourceId: string;
sourceType: WorkActivity['sourceType'];
action: WorkActivity['action'];
category: WorkActivityCategory;
};
export function buildXiaobaoWorkItems(input: BuildXiaobaoWorkItemsInput): WorkItem[] {
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
const requirementVersionMap = normalizeRequirementVersionMap(input.requirementVersionMap);
@@ -167,39 +178,66 @@ export function buildVersionDailyEvidence(input: BuildVersionDailyEvidenceInput)
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 activityKeys = new Set(todayActivities.map(getActivityKey));
const evidenceSourceIds = new Set([
...todayActivities.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
const fallbackEvidence = buildTimestampFallbackEvidence(versionItems, today, activityKeys, evidenceSourceIds);
const activityEvidence = [
...todayActivities.map(activityToEvidenceDraft),
...fallbackEvidence,
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const evidenceActivities = [
...versionActivities,
...fallbackEvidence.map(evidenceDraftToActivity),
];
const recentSince = now.getTime() - 3 * 86_400_000;
const recentActivityCount = [
...versionActivities.map((activity) => activity.occurredAt),
...evidenceActivities.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),
...evidenceActivities.map((activity) => activity.occurredAt),
...versionWorklogs.map((worklog) => worklog.createdAt),
]);
const touchedTodayIds = new Set([
...todayActivities.map((activity) => activity.sourceId),
...activityEvidence.map((activity) => activity.sourceId),
...todayWorklogs.map((worklog) => worklog.taskId),
]);
const todayDeliveries = activityEvidence.filter((activity) => activity.category === 'delivery').map(evidenceDraftToEvidenceItem);
const todayProgress = activityEvidence.filter((activity) => activity.category === 'progress').map(evidenceDraftToEvidenceItem);
const todayCreations = activityEvidence.filter((activity) => activity.category === 'creation').map(evidenceDraftToEvidenceItem);
const todayRisks = activityEvidence.filter((activity) => activity.category === 'risk').map(evidenceDraftToEvidenceItem);
const progressNotes = [
...activityEvidence.filter((activity) => activity.category === 'note').map(evidenceDraftToEvidenceItem),
...todayWorklogs.map((worklog) => worklogToEvidenceItem(worklog, itemMap.get(worklog.taskId))),
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const worklogTaskIds = new Set(todayWorklogs.map((worklog) => worklog.taskId));
const activityHours = Array.from(new Set(activityEvidence.map((activity) => activity.sourceId)))
.filter((sourceId) => !worklogTaskIds.has(sourceId))
.reduce((sum, sourceId) => sum + calcWorkItemHoursForDate(itemMap.get(sourceId), today, now), 0);
const worklogHours = todayWorklogs.reduce((sum, worklog) => sum + worklog.hours, 0);
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)),
todayDeliveries,
todayProgress,
todayCreations,
todayRisks,
progressNotes,
needsProgressItems: versionItems
.filter((item) => shouldNeedProgress(item, touchedTodayIds, versionActivities, versionWorklogs, now))
.filter((item) => shouldNeedProgress(item, touchedTodayIds, evidenceActivities, versionWorklogs, now))
.map(itemToEvidenceItem),
recentActivityCount,
totalActivityCount: todayDeliveries.length + todayProgress.length + todayCreations.length + todayRisks.length + progressNotes.length,
todayActualHours: roundHalfHour(worklogHours + activityHours),
lastActivityAt,
silentRisks: buildSilentRisks(versionItems, versionActivities, versionWorklogs, now),
silentRisks: buildSilentRisks(versionItems, evidenceActivities, versionWorklogs, now),
};
}
@@ -217,6 +255,41 @@ function activityToEvidenceItem(activity: WorkActivity): EvidenceItem {
};
}
function activityToEvidenceDraft(activity: WorkActivity): EvidenceDraft {
return {
...activityToEvidenceItem(activity),
sourceId: activity.sourceId,
sourceType: activity.sourceType,
action: activity.action,
category: activity.category,
};
}
function evidenceDraftToEvidenceItem(item: EvidenceDraft): EvidenceItem {
return {
id: item.id,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
actorId: item.actorId,
};
}
function evidenceDraftToActivity(item: EvidenceDraft): WorkActivity {
return {
id: item.id,
actorId: item.actorId ?? '',
date: item.occurredAt.slice(0, 10),
sourceType: item.sourceType,
sourceId: item.sourceId,
action: item.action,
category: item.category,
title: item.title,
summary: item.summary,
occurredAt: item.occurredAt,
};
}
function worklogToEvidenceItem(worklog: TaskWorklog, item?: WorkItem): EvidenceItem {
return {
id: worklog.id,
@@ -236,6 +309,109 @@ function itemToEvidenceItem(item: WorkItem): EvidenceItem {
};
}
function buildTimestampFallbackEvidence(
items: WorkItem[],
date: string,
existingKeys: Set<string>,
evidenceSourceIds: Set<string>,
): EvidenceDraft[] {
const out: EvidenceDraft[] = [];
for (const item of items) {
if (evidenceSourceIds.has(item.id)) continue;
for (const draft of getTimestampFallbackDrafts(item)) {
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
const key = `${item.id}:${draft.action}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
out.push({
id: `fallback-${draft.action}-${item.id}`,
title: item.title,
sourceId: item.id,
sourceType: draft.sourceType,
action: draft.action,
category: draft.category,
summary: draft.summary,
occurredAt: draft.occurredAt,
});
}
}
return out;
}
function getTimestampFallbackDrafts(
item: WorkItem,
): Array<Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'>> {
const raw = getRaw(item);
const extra = item.extra ?? {};
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
return [
makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`),
makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`),
makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`),
].filter(isFallbackDraft);
}
if (item.type === 'devTask') {
return [
makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`),
makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`),
makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`),
].filter(isFallbackDraft);
}
if (item.type === 'testCase') {
const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt);
const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt);
return [
makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`),
makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`),
terminal,
].filter(isFallbackDraft);
}
if (item.type === 'bug') {
return [
makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug${item.title}`),
].filter(isFallbackDraft);
}
return [];
}
function makeFallbackDraft(
sourceType: WorkActivity['sourceType'],
action: WorkActivity['action'],
category: WorkActivityCategory,
occurredAt: string | undefined,
summary: string,
): Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (!occurredAt) return undefined;
return { sourceType, action, category, summary, occurredAt };
}
function isFallbackDraft(
draft: Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
): draft is Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
return Boolean(draft);
}
function getTestCaseTerminalFallback(
status: string,
title: string,
completedAt: string | undefined,
): Pick<EvidenceDraft, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`);
if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`);
if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`);
return undefined;
}
function buildSilentRisks(
items: WorkItem[],
activities: WorkActivity[],
@@ -328,6 +504,102 @@ function getLatestItemTouchAt(item: WorkItem): string | undefined {
]);
}
function calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number {
const interval = getActualInterval(item, now);
if (!interval) return 0;
const day = getLocalDateBounds(date);
if (!day) return 0;
const start = new Date(interval.start).getTime();
const end = new Date(interval.end).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return 0;
return calcActualElapsedHours(
new Date(overlapStart).toISOString(),
new Date(overlapEnd).toISOString(),
);
}
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
if (!item) return undefined;
const raw = getRaw(item);
const extra = item.extra ?? {};
const nowIso = now.toISOString();
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
const end = asString(raw.completedAt) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'devTask') {
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'testCase') {
const start = asString(raw.startedAt) ?? asString(extra.startedAt);
const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status);
const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'bug') {
const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt);
const isTerminal = ['closed', 'rejected'].includes(item.status);
const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
return undefined;
}
function getBugFixingStartedAt(raw: Record<string, unknown>): string | undefined {
const logs = raw.logs;
if (!Array.isArray(logs)) return undefined;
const fixingLog = logs.find((log) => {
if (!log || typeof log !== 'object') return false;
const row = log as Record<string, unknown>;
return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string';
}) as Record<string, unknown> | undefined;
return asString(fixingLog?.createdAt);
}
function getActivityKey(activity: Pick<WorkActivity, 'sourceId' | 'action'>): string {
return `${activity.sourceId}:${activity.action}`;
}
function getRaw(item: WorkItem): Record<string, unknown> {
return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
}
function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined;
const start = new Date(year, month - 1, day, 0, 0, 0, 0);
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0);
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined;
return { start, end };
}
function isWithinLocalDate(value: string, date: string): boolean {
const bounds = getLocalDateBounds(date);
if (!bounds) return false;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return false;
return time >= bounds.start.getTime() && time < bounds.end.getTime();
}
function latestIso(values: Array<string | undefined>): string | undefined {
let latest: string | undefined;
let latestTime = Number.NEGATIVE_INFINITY;
@@ -358,3 +630,8 @@ function parseDate(value: string | undefined): Date | undefined {
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function roundHalfHour(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 0;
return Math.round(hours * 2) / 2;
}