feat(FN-5093): complete Step 1 — core detector module
Fusion-Task-Id: FN-5093 Fusion-Task-Lineage: fe96617a-88f8-4a3c-b96c-c516d2206331
This commit is contained in:
committed by
gsxdsm
parent
85cb845b87
commit
d251cbecd1
123
packages/core/src/__tests__/in-review-stalled.test.ts
Normal file
123
packages/core/src/__tests__/in-review-stalled.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS, getInReviewStalledSignal } from "../in-review-stalled.js";
|
||||
|
||||
const NOW = Date.parse("2026-05-19T12:00:00.000Z");
|
||||
|
||||
function iso(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
const baseTask = {
|
||||
id: "FN-5093-A",
|
||||
column: "in-review" as const,
|
||||
paused: false,
|
||||
status: "in-review" as const,
|
||||
columnMovedAt: iso(NOW - DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS - 1_000),
|
||||
updatedAt: iso(NOW - DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS - 1_000),
|
||||
mergeDetails: {},
|
||||
log: [],
|
||||
};
|
||||
|
||||
describe("getInReviewStalledSignal", () => {
|
||||
it("returns signal for quiet in-review task beyond threshold", () => {
|
||||
const signal = getInReviewStalledSignal(baseTask, { now: NOW });
|
||||
expect(signal?.code).toBe("in-review-stalled");
|
||||
expect(signal?.quietMs).toBeGreaterThanOrEqual(DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS);
|
||||
expect(signal?.lastActivitySource).toBe("column-moved");
|
||||
});
|
||||
|
||||
it("returns undefined for paused task", () => {
|
||||
expect(getInReviewStalledSignal({ ...baseTask, paused: true }, { now: NOW })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for non in-review column", () => {
|
||||
expect(getInReviewStalledSignal({ ...baseTask, column: "todo" }, { now: NOW })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when autoMerge is disabled", () => {
|
||||
expect(getInReviewStalledSignal(baseTask, { now: NOW, autoMerge: false })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when merge is already confirmed", () => {
|
||||
expect(getInReviewStalledSignal({ ...baseTask, mergeDetails: { mergeConfirmed: true } }, { now: NOW })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for awaiting-user-review", () => {
|
||||
expect(getInReviewStalledSignal({ ...baseTask, status: "awaiting-user-review" }, { now: NOW })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for awaiting-approval", () => {
|
||||
expect(getInReviewStalledSignal({ ...baseTask, status: "awaiting-approval" }, { now: NOW })).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["merging", "merging-pr", "merging-fix"])("returns undefined for active merge status %s", (status) => {
|
||||
expect(getInReviewStalledSignal({ ...baseTask, status }, { now: NOW })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for active merge owner", () => {
|
||||
expect(getInReviewStalledSignal(baseTask, { now: NOW, activeMergeTaskId: baseTask.id })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when task is currently executing", () => {
|
||||
expect(getInReviewStalledSignal(baseTask, {
|
||||
now: NOW,
|
||||
executingTaskIds: new Set([baseTask.id]),
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses when recent log activity is within threshold and emits once aged out", () => {
|
||||
const withRecentLog = {
|
||||
...baseTask,
|
||||
log: [{ timestamp: iso(NOW - 5_000), action: "random activity" }],
|
||||
};
|
||||
expect(getInReviewStalledSignal(withRecentLog, { now: NOW, thresholdMs: 10_000 })).toBeUndefined();
|
||||
|
||||
const later = getInReviewStalledSignal(withRecentLog, { now: NOW + 15_000, thresholdMs: 10_000 });
|
||||
expect(later?.code).toBe("in-review-stalled");
|
||||
});
|
||||
|
||||
it("suppresses while recent reason-driven stall log exists and emits after it ages out", () => {
|
||||
const task = {
|
||||
...baseTask,
|
||||
log: [{ timestamp: iso(NOW - 2_000), action: "In-review stall surfaced [merge-blocker]: blocked" }],
|
||||
};
|
||||
expect(getInReviewStalledSignal(task, { now: NOW, thresholdMs: 10_000 })).toBeUndefined();
|
||||
|
||||
const later = getInReviewStalledSignal(task, { now: NOW + 12_000, thresholdMs: 10_000 });
|
||||
expect(later?.code).toBe("in-review-stalled");
|
||||
});
|
||||
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])("returns undefined for invalid threshold %s", (thresholdMs) => {
|
||||
expect(getInReviewStalledSignal(baseTask, { now: NOW, thresholdMs })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses log as last activity source when newest", () => {
|
||||
const signal = getInReviewStalledSignal({
|
||||
...baseTask,
|
||||
columnMovedAt: iso(NOW - 30_000),
|
||||
updatedAt: iso(NOW - 20_000),
|
||||
log: [{ timestamp: iso(NOW - 10_000), action: "newest" }],
|
||||
}, { now: NOW, thresholdMs: 10_000 });
|
||||
expect(signal?.lastActivitySource).toBe("log");
|
||||
});
|
||||
|
||||
it("uses column-moved when it is newest and log is absent", () => {
|
||||
const signal = getInReviewStalledSignal({
|
||||
...baseTask,
|
||||
columnMovedAt: iso(NOW - 12_000),
|
||||
updatedAt: iso(NOW - 20_000),
|
||||
log: [],
|
||||
}, { now: NOW, thresholdMs: 10_000 });
|
||||
expect(signal?.lastActivitySource).toBe("column-moved");
|
||||
});
|
||||
|
||||
it("uses updated when only updatedAt is parseable", () => {
|
||||
const signal = getInReviewStalledSignal({
|
||||
...baseTask,
|
||||
columnMovedAt: "not-a-date",
|
||||
updatedAt: iso(NOW - 12_000),
|
||||
log: [],
|
||||
}, { now: NOW, thresholdMs: 10_000 });
|
||||
expect(signal?.lastActivitySource).toBe("updated");
|
||||
});
|
||||
});
|
||||
129
packages/core/src/in-review-stalled.ts
Normal file
129
packages/core/src/in-review-stalled.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { IN_REVIEW_STALL_LOG_PREFIX } from "./in-review-stall.js";
|
||||
import type { Task } from "./types.js";
|
||||
|
||||
export type InReviewStalledCode = "in-review-stalled";
|
||||
|
||||
export interface InReviewStalledSignal {
|
||||
code: InReviewStalledCode;
|
||||
reason: string;
|
||||
observedAt: string;
|
||||
ageMs: number;
|
||||
quietMs: number;
|
||||
thresholdMs: number;
|
||||
lastActivityAt: string;
|
||||
lastActivitySource: "log" | "column-moved" | "updated";
|
||||
}
|
||||
|
||||
export interface InReviewStalledContext {
|
||||
now?: number;
|
||||
thresholdMs?: number;
|
||||
autoMerge?: boolean;
|
||||
activeMergeTaskId?: string | null;
|
||||
executingTaskIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export const DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS = 24 * 60 * 60_000;
|
||||
|
||||
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
|
||||
|
||||
type InReviewStalledTask = Pick<Task, "id" | "column" | "paused" | "status" | "columnMovedAt" | "updatedAt" | "log" | "mergeDetails">;
|
||||
|
||||
type ActivityCandidate = {
|
||||
time: number;
|
||||
source: "log" | "column-moved" | "updated";
|
||||
tiePriority: number;
|
||||
};
|
||||
|
||||
export function getInReviewStalledSignal(
|
||||
task: InReviewStalledTask,
|
||||
context: InReviewStalledContext = {},
|
||||
): InReviewStalledSignal | undefined {
|
||||
if (task.column !== "in-review" || task.paused === true) return undefined;
|
||||
if (context.autoMerge === false) return undefined;
|
||||
if (task.mergeDetails?.mergeConfirmed === true) return undefined;
|
||||
if (task.status === "awaiting-user-review" || task.status === "awaiting-approval") return undefined;
|
||||
if (task.status && ACTIVE_MERGE_STATUSES.has(task.status)) return undefined;
|
||||
if (context.activeMergeTaskId === task.id || context.executingTaskIds?.has(task.id)) return undefined;
|
||||
|
||||
const thresholdMs = context.thresholdMs ?? DEFAULT_IN_REVIEW_STALLED_THRESHOLD_MS;
|
||||
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return undefined;
|
||||
|
||||
const now = context.now ?? Date.now();
|
||||
if (hasRecentReasonDrivenStall(task.log ?? [], now - thresholdMs)) return undefined;
|
||||
|
||||
const lastActivity = getLastActivity(task);
|
||||
if (!lastActivity) return undefined;
|
||||
|
||||
const quietMs = Math.max(0, now - lastActivity.time);
|
||||
if (quietMs < thresholdMs) return undefined;
|
||||
|
||||
const ageAnchor = Date.parse(task.columnMovedAt ?? task.updatedAt);
|
||||
if (!Number.isFinite(ageAnchor)) return undefined;
|
||||
|
||||
const ageMs = Math.max(0, now - ageAnchor);
|
||||
const thresholdHours = thresholdMs / 3_600_000;
|
||||
const quietHours = quietMs / 3_600_000;
|
||||
|
||||
return {
|
||||
code: "in-review-stalled",
|
||||
reason: `In-review task quiet for ${quietHours.toFixed(1)}h beyond ${thresholdHours.toFixed(1)}h threshold`,
|
||||
observedAt: new Date(now).toISOString(),
|
||||
ageMs,
|
||||
quietMs,
|
||||
thresholdMs,
|
||||
lastActivityAt: new Date(lastActivity.time).toISOString(),
|
||||
lastActivitySource: lastActivity.source,
|
||||
};
|
||||
}
|
||||
|
||||
function hasRecentReasonDrivenStall(log: readonly Pick<Task["log"][number], "action" | "timestamp">[], floor: number): boolean {
|
||||
let latestTime = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const entry of log) {
|
||||
if (!entry.action.startsWith(IN_REVIEW_STALL_LOG_PREFIX)) continue;
|
||||
const entryTime = Date.parse(entry.timestamp);
|
||||
if (!Number.isFinite(entryTime)) continue;
|
||||
if (entryTime > latestTime) latestTime = entryTime;
|
||||
}
|
||||
|
||||
return Number.isFinite(latestTime) && latestTime >= floor;
|
||||
}
|
||||
|
||||
function getLastActivity(task: InReviewStalledTask): ActivityCandidate | undefined {
|
||||
const candidates: ActivityCandidate[] = [];
|
||||
|
||||
const logTime = getLatestLogTimestamp(task.log ?? []);
|
||||
if (Number.isFinite(logTime)) {
|
||||
candidates.push({ time: logTime, source: "log", tiePriority: 0 });
|
||||
}
|
||||
|
||||
const columnMovedTime = Date.parse(task.columnMovedAt ?? "");
|
||||
if (Number.isFinite(columnMovedTime)) {
|
||||
candidates.push({ time: columnMovedTime, source: "column-moved", tiePriority: 1 });
|
||||
}
|
||||
|
||||
const updatedAtTime = Date.parse(task.updatedAt);
|
||||
if (Number.isFinite(updatedAtTime)) {
|
||||
candidates.push({ time: updatedAtTime, source: "updated", tiePriority: 2 });
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return undefined;
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
if (a.time !== b.time) return b.time - a.time;
|
||||
return a.tiePriority - b.tiePriority;
|
||||
});
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function getLatestLogTimestamp(log: readonly Pick<Task["log"][number], "timestamp">[]): number {
|
||||
let latest = Number.NEGATIVE_INFINITY;
|
||||
for (const entry of log) {
|
||||
const entryTime = Date.parse(entry.timestamp);
|
||||
if (Number.isFinite(entryTime) && entryTime > latest) {
|
||||
latest = entryTime;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
Reference in New Issue
Block a user