361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
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;
|
|
title: string;
|
|
summary: string;
|
|
occurredAt: string;
|
|
actorId?: string;
|
|
}
|
|
|
|
export interface VersionDailyEvidence {
|
|
todayDeliveries: EvidenceItem[];
|
|
todayProgress: EvidenceItem[];
|
|
todayRisks: EvidenceItem[];
|
|
progressNotes: EvidenceItem[];
|
|
needsProgressItems: EvidenceItem[];
|
|
recentActivityCount: number;
|
|
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<string, string> | Record<string, string>;
|
|
}
|
|
|
|
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<string, string> | Record<string, string>): Map<string, string> {
|
|
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<string>,
|
|
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<string, unknown>;
|
|
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>): 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;
|
|
}
|