feat(FN-5091): complete Step 3 — add dependency blocked todo reporter
Fusion-Task-Id: FN-5091 Fusion-Task-Lineage: f59dc7dd-abc2-4d1d-a755-1fe446314131
This commit is contained in:
committed by
gsxdsm
parent
8e3e71744b
commit
2bc1c023c8
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import {
|
||||
DependencyBlockedTodoReporter,
|
||||
DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX,
|
||||
} from "../dependency-blocked-todo-reporter.js";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-1",
|
||||
description: "test",
|
||||
title: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
paused: false,
|
||||
status: undefined,
|
||||
blockedBy: "",
|
||||
overlapBlockedBy: "",
|
||||
log: [],
|
||||
createdAt: "2026-05-18T00:00:00.000Z",
|
||||
updatedAt: "2026-05-18T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(params: {
|
||||
settings?: Record<string, unknown>;
|
||||
tasks?: Task[];
|
||||
insightStore?: { upsertInsight: ReturnType<typeof vi.fn>; listInsights: ReturnType<typeof vi.fn> };
|
||||
throwInsightStore?: boolean;
|
||||
}): TaskStore {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue(params.settings ?? {}),
|
||||
listTasks: vi.fn().mockResolvedValue(params.tasks ?? []),
|
||||
getInsightStore: vi.fn().mockImplementation(() => {
|
||||
if (params.throwInsightStore) throw new Error("missing insight store");
|
||||
return params.insightStore;
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
describe("DependencyBlockedTodoReporter", () => {
|
||||
const logger = { warn: vi.fn(), error: vi.fn() };
|
||||
const now = Date.parse("2026-05-18T12:00:00.000Z");
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("no-ops when disabled", async () => {
|
||||
const store = createStore({ settings: { dependencyBlockedTodoReportEnabled: false } });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "disabled" });
|
||||
expect(store.logEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid config", async () => {
|
||||
const store = createStore({ settings: { dependencyBlockedTodoFreshAgeMs: -1 } });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "invalid-config" });
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no-blocked-groups when no dependency-blocked todos exist", async () => {
|
||||
const store = createStore({ tasks: [createTask({ id: "FN-0", column: "in-progress" })] });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "no-blocked-groups" });
|
||||
});
|
||||
|
||||
it("suppresses single fresh group noise", async () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-B", column: "in-progress", columnMovedAt: "2026-05-18T11:58:00.000Z" }),
|
||||
createTask({ id: "FN-T1", dependencies: ["FN-B"] }),
|
||||
];
|
||||
const store = createStore({ tasks });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "below-significance" });
|
||||
});
|
||||
|
||||
it("emits stale grouped insight payload", async () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-5090", column: "in-progress", title: "Blocker", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
|
||||
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
|
||||
];
|
||||
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
|
||||
const store = createStore({ tasks, insightStore });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
|
||||
const result = await reporter.report();
|
||||
expect(result).toEqual({ alerted: true, groupCount: 1 });
|
||||
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
|
||||
const payload = insightStore.upsertInsight.mock.calls[0][1];
|
||||
expect(payload.title).toBe(`${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-18`);
|
||||
expect(payload.category).toBe("workflow");
|
||||
expect(payload.provenance.relatedEntityIds).toEqual(["FN-5090"]);
|
||||
const content = JSON.parse(payload.content);
|
||||
expect(content.groups).toHaveLength(1);
|
||||
expect(content.groups[0]).toMatchObject({ blockerId: "FN-5090", blockedTodoCount: 3, blockerTitle: "Blocker" });
|
||||
});
|
||||
|
||||
it("suppresses under cooldown", async () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
|
||||
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
|
||||
];
|
||||
const insightStore = {
|
||||
upsertInsight: vi.fn(),
|
||||
listInsights: vi.fn().mockReturnValue([{ title: `${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-18`, updatedAt: "2026-05-18T11:59:30.000Z" }]),
|
||||
};
|
||||
const store = createStore({ tasks, insightStore, settings: { dependencyBlockedTodoReportCooldownMs: 60_000 } });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "cooldown" });
|
||||
});
|
||||
|
||||
it("re-emits outside cooldown", async () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
|
||||
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
|
||||
];
|
||||
const insightStore = {
|
||||
upsertInsight: vi.fn(),
|
||||
listInsights: vi.fn().mockReturnValue([{ title: `${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-17`, updatedAt: "2026-05-18T11:58:00.000Z" }]),
|
||||
};
|
||||
const store = createStore({ tasks, insightStore, settings: { dependencyBlockedTodoReportCooldownMs: 60_000 } });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: true, groupCount: 1 });
|
||||
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to logEntry when insight store unavailable", async () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }),
|
||||
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
|
||||
];
|
||||
const store = createStore({ tasks, throwInsightStore: true });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now });
|
||||
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: true, groupCount: 1 });
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-5090", expect.stringContaining("[dependency-blocked-todo]"));
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors injected now for deterministic age bucket", async () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T11:20:00.000Z" }),
|
||||
createTask({ id: "FN-5034", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5085", dependencies: ["FN-5090"] }),
|
||||
createTask({ id: "FN-5089", dependencies: ["FN-5090"] }),
|
||||
];
|
||||
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
|
||||
const store = createStore({ tasks, insightStore });
|
||||
const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => Date.parse("2026-05-18T12:00:00.000Z") });
|
||||
await reporter.report();
|
||||
const content = JSON.parse(insightStore.upsertInsight.mock.calls[0][1].content);
|
||||
expect(content.groups[0].ageBucket).toBe("aging");
|
||||
});
|
||||
});
|
||||
165
packages/engine/src/dependency-blocked-todo-reporter.ts
Normal file
165
packages/engine/src/dependency-blocked-todo-reporter.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
computeDependencyBlockedTodoReport,
|
||||
computeInsightFingerprint,
|
||||
DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS,
|
||||
type TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const reporterLog = createLogger("dependency-blocked-todo");
|
||||
const TITLE_PREFIX = "Backlog health: dependency-blocked todos";
|
||||
|
||||
type DependencyBlockedTodoReporterLogger = {
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error?: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
interface DependencyBlockedTodoReporterOptions {
|
||||
store: TaskStore;
|
||||
projectId: string;
|
||||
logger?: DependencyBlockedTodoReporterLogger;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class DependencyBlockedTodoReporter {
|
||||
private readonly store: TaskStore;
|
||||
private readonly projectId: string;
|
||||
private readonly logger: DependencyBlockedTodoReporterLogger;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: DependencyBlockedTodoReporterOptions) {
|
||||
this.store = options.store;
|
||||
this.projectId = options.projectId;
|
||||
this.logger = options.logger ?? reporterLog;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
async report(): Promise<{ alerted: boolean; reason?: string; groupCount?: number }> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.dependencyBlockedTodoReportEnabled === false) {
|
||||
return { alerted: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
const freshAgeMs = settings.dependencyBlockedTodoFreshAgeMs ?? 30 * 60_000;
|
||||
const staleAgeMs = settings.dependencyBlockedTodoStaleAgeMs ?? 4 * 60 * 60_000;
|
||||
const minBlockedTodoCount = settings.dependencyBlockedTodoMinCount ?? 1;
|
||||
const cooldownMs = settings.dependencyBlockedTodoReportCooldownMs ?? 6 * 60 * 60_000;
|
||||
if (
|
||||
!Number.isFinite(freshAgeMs) ||
|
||||
freshAgeMs <= 0 ||
|
||||
!Number.isFinite(staleAgeMs) ||
|
||||
staleAgeMs <= 0 ||
|
||||
!Number.isFinite(minBlockedTodoCount) ||
|
||||
minBlockedTodoCount <= 0 ||
|
||||
!Number.isFinite(cooldownMs) ||
|
||||
cooldownMs < 0
|
||||
) {
|
||||
this.logger.warn("[dependency-blocked-todo] invalid config: thresholds must be valid finite values");
|
||||
return { alerted: false, reason: "invalid-config" };
|
||||
}
|
||||
|
||||
const maxAutoMergeRetries = settings.maxAutoMergeRetries ?? 3;
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
|
||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
||||
const nowMs = this.now();
|
||||
const report = computeDependencyBlockedTodoReport(tasks, maxAutoMergeRetries, {
|
||||
now: nowMs,
|
||||
freshAgeMs,
|
||||
staleAgeMs,
|
||||
minBlockedTodoCount,
|
||||
maxGroups: DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS,
|
||||
});
|
||||
|
||||
if (report.uniqueBlockerCount === 0) {
|
||||
return { alerted: false, reason: "no-blocked-groups" };
|
||||
}
|
||||
|
||||
const hasAgingOrStale = report.groups.some((group) => group.ageBucket !== "fresh");
|
||||
if (!hasAgingOrStale && report.totalBlockedTodoCount < 3) {
|
||||
return { alerted: false, reason: "below-significance" };
|
||||
}
|
||||
|
||||
const detectedAt = new Date(nowMs).toISOString();
|
||||
const title = `${TITLE_PREFIX} ${detectedAt.slice(0, 10)}`;
|
||||
const contentPayload = {
|
||||
observedAt: report.observedAt,
|
||||
totalBlockedTodoCount: report.totalBlockedTodoCount,
|
||||
uniqueBlockerCount: report.uniqueBlockerCount,
|
||||
thresholds: report.thresholds,
|
||||
groups: report.groups.map((group) => ({
|
||||
blockerId: group.blockerId,
|
||||
blockerColumn: group.blockerColumn,
|
||||
blockerTitle: taskById.get(group.blockerId)?.title,
|
||||
blockedTodoCount: group.blockedTodoCount,
|
||||
ageBucket: group.ageBucket,
|
||||
blockingAgeMs: group.blockingAgeMs,
|
||||
blockedTodoIds: group.blockedTodoIds.slice(0, 10),
|
||||
viaDependencies: group.viaDependencies.slice(0, 10),
|
||||
viaBlockedBy: group.viaBlockedBy.slice(0, 10),
|
||||
})),
|
||||
};
|
||||
const content = JSON.stringify(contentPayload);
|
||||
|
||||
let insightStore;
|
||||
try {
|
||||
if (!this.projectId) throw new Error("empty projectId");
|
||||
insightStore = this.store.getInsightStore();
|
||||
} catch (error) {
|
||||
await this.store.logEntry(report.groups[0].blockerId, `[dependency-blocked-todo] ${content}`);
|
||||
this.logger.warn("[dependency-blocked-todo] insight store unavailable; logged fallback payload", error);
|
||||
this.logger.warn(
|
||||
`[dependency-blocked-todo] alert: groups=${report.uniqueBlockerCount} blockedTodos=${report.totalBlockedTodoCount} blockers=${report.groups
|
||||
.slice(0, 3)
|
||||
.map((group) => group.blockerId)
|
||||
.join(",")}`,
|
||||
);
|
||||
return { alerted: true, groupCount: report.uniqueBlockerCount };
|
||||
}
|
||||
|
||||
if (cooldownMs > 0) {
|
||||
const insights = insightStore.listInsights({
|
||||
projectId: this.projectId,
|
||||
category: "workflow",
|
||||
status: "generated",
|
||||
limit: 10,
|
||||
});
|
||||
const latest = [...insights]
|
||||
.filter((insight) => insight.title.startsWith(TITLE_PREFIX))
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
|
||||
if (latest) {
|
||||
const updatedAtMs = Date.parse(latest.updatedAt);
|
||||
if (Number.isFinite(updatedAtMs) && nowMs - updatedAtMs < cooldownMs) {
|
||||
return { alerted: false, reason: "cooldown" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insightStore.upsertInsight(this.projectId, {
|
||||
title,
|
||||
content,
|
||||
category: "workflow",
|
||||
fingerprint: computeInsightFingerprint(title, "workflow"),
|
||||
provenance: {
|
||||
trigger: "schedule",
|
||||
description: "Dependency-blocked Todo grouping (generated by dependency-blocked-todo-reporter)",
|
||||
relatedEntityIds: report.groups.map((group) => group.blockerId),
|
||||
metadata: { generator: "dependency-blocked-todo-reporter" },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(
|
||||
`[dependency-blocked-todo] alert: groups=${report.uniqueBlockerCount} blockedTodos=${report.totalBlockedTodoCount} blockers=${report.groups
|
||||
.slice(0, 3)
|
||||
.map((group) => group.blockerId)
|
||||
.join(",")}`,
|
||||
);
|
||||
return { alerted: true, groupCount: report.uniqueBlockerCount };
|
||||
} catch (error) {
|
||||
this.logger.error?.("[dependency-blocked-todo] reporter failed", error);
|
||||
return { alerted: false, reason: "error" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { TITLE_PREFIX as DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX };
|
||||
Reference in New Issue
Block a user