feat(KB-106): remove duplicate ntfy notifications and add per-event-type deduplication
- Add per-event-type deduplication using Set to track recent notification hashes - Remove auto browser-open behavior from dashboard command - Add recentWindowMs configuration option for deduplication window timing - Update notifier tests for new deduplication logic and error handling - Add changeset for ntfy duplicate notifications fix - Remove stale changeset for auto browser-open feature
This commit is contained in:
@@ -137,7 +137,7 @@ describe("NtfyNotifier", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends notification when task moves to done", async () => {
|
||||
it("does not send notification when task moves to done (merged notification comes from task:merged)", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
@@ -145,18 +145,8 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "Task KB-001 merged",
|
||||
"Priority": "default",
|
||||
}),
|
||||
body: 'Task "Test Task" has been merged to main',
|
||||
})
|
||||
);
|
||||
// handleTaskMoved should NOT send notification for "done" - that's handleTaskMerged's job
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends high priority notification when task fails", async () => {
|
||||
@@ -335,46 +325,92 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("debouncing", () => {
|
||||
describe("deduplication", () => {
|
||||
beforeEach(() => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("prevents duplicate notifications within debounce window", async () => {
|
||||
it("prevents duplicate notifications for the same event type", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task");
|
||||
|
||||
// Rapid transitions
|
||||
// Multiple in-review events for the same task
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMoved(task, "done", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Should only send one notification due to debouncing
|
||||
// Should only send one notification due to deduplication
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows notifications after debounce window", async () => {
|
||||
it("allows different event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for debounce window (5 seconds) - use fake timers or access internal state
|
||||
// For this test, we'll create a new task to verify separate tasks aren't debounced together
|
||||
const task2 = createTask("KB-002", "Test Task 2");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
// Second: merged notification (different event type - should be allowed)
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "kb/kb-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Different task ID should get its own notification
|
||||
// Should have two notifications now
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("prevents duplicate task:merged events for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task");
|
||||
const mergeResult: MergeResult = {
|
||||
task,
|
||||
branch: "kb/kb-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
|
||||
// Multiple merged events for the same task
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Should only send one notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows notifications for different tasks independently", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task1 = createTask("KB-001", "Test Task 1");
|
||||
const task2 = createTask("KB-002", "Test Task 2");
|
||||
|
||||
store.triggerTaskMoved(task1, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Different tasks should each get their own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -421,7 +457,29 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("does not notify on task:moved to columns other than in-review or done", async () => {
|
||||
it("allows in-review and failed notifications for the same task", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("KB-001", "Test Task");
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: failed notification (different event type - should be allowed)
|
||||
const failedTask = { ...task, status: "failed" };
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Should have two notifications
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not notify on task:moved to columns other than in-review", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
@@ -435,6 +493,10 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskMoved(createTask("KB-002", "Test Task 2"), "todo", "in-progress");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Move to done - should not notify (merged notification comes from task:merged)
|
||||
store.triggerTaskMoved(createTask("KB-003", "Test Task 3"), "in-review", "done");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ interface NtfyConfig {
|
||||
topic: string | undefined;
|
||||
}
|
||||
|
||||
/** Event types for notification deduplication */
|
||||
type NotificationEventType = "in-review" | "merged" | "failed";
|
||||
|
||||
/**
|
||||
* NtfyNotifier sends push notifications via ntfy.sh when tasks complete
|
||||
* or fail. It listens to TaskStore events and sends HTTP POST requests
|
||||
@@ -19,16 +22,14 @@ interface NtfyConfig {
|
||||
* Features:
|
||||
* - Runtime reconfiguration via settings:updated events
|
||||
* - Best-effort delivery (errors are logged but never thrown)
|
||||
* - Duplicate prevention for rapid column transitions
|
||||
* - Duplicate prevention per event type (in-review, merged, failed)
|
||||
* - Configurable notification events (hardcoded defaults)
|
||||
*/
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined };
|
||||
private ntfyBaseUrl: string;
|
||||
/** Tracks last notification time per task to prevent duplicates */
|
||||
private lastNotificationTime: Map<string, number> = new Map();
|
||||
/** Minimum interval between notifications for the same task (ms) */
|
||||
private debounceMs = 5000;
|
||||
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
||||
private notifiedEvents: Set<string> = new Set();
|
||||
/** AbortController for in-flight requests during shutdown */
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
@@ -91,7 +92,7 @@ export class NtfyNotifier {
|
||||
|
||||
// Notify when task moves to in-review (completed work, ready for review)
|
||||
if (to === "in-review") {
|
||||
this.maybeNotify(task.id, () =>
|
||||
this.maybeNotify(task.id, "in-review", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} completed`,
|
||||
@@ -101,17 +102,8 @@ export class NtfyNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
// Notify when task moves to done (merged to main)
|
||||
if (to === "done") {
|
||||
this.maybeNotify(task.id, () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} merged`,
|
||||
`Task "${task.title ?? task.id}" has been merged to main`,
|
||||
"default",
|
||||
),
|
||||
);
|
||||
}
|
||||
// Note: "done" notifications come from handleTaskMerged (task:merged event)
|
||||
// to avoid duplicate notifications when moveToDone is called before merge
|
||||
};
|
||||
|
||||
private handleTaskUpdated = (task: Task): void => {
|
||||
@@ -119,7 +111,7 @@ export class NtfyNotifier {
|
||||
|
||||
// Notify when task fails
|
||||
if (task.status === "failed") {
|
||||
this.maybeNotify(task.id, () =>
|
||||
this.maybeNotify(task.id, "failed", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} failed`,
|
||||
@@ -135,7 +127,7 @@ export class NtfyNotifier {
|
||||
|
||||
// Only notify on successful merges
|
||||
if (result.merged) {
|
||||
this.maybeNotify(result.task.id, () =>
|
||||
this.maybeNotify(result.task.id, "merged", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${result.task.id} merged`,
|
||||
@@ -173,19 +165,22 @@ export class NtfyNotifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification if enough time has passed since last notification for this task.
|
||||
* This prevents duplicate notifications during rapid column transitions.
|
||||
* Send notification if this (taskId, eventType) pair hasn't been notified before.
|
||||
* This prevents duplicate notifications for the same event type per task.
|
||||
*/
|
||||
private maybeNotify(taskId: string, notifyFn: () => Promise<void>): void {
|
||||
const now = Date.now();
|
||||
const lastTime = this.lastNotificationTime.get(taskId);
|
||||
private maybeNotify(
|
||||
taskId: string,
|
||||
eventType: NotificationEventType,
|
||||
notifyFn: () => Promise<void>,
|
||||
): void {
|
||||
const key = `${taskId}:${eventType}`;
|
||||
|
||||
if (lastTime && now - lastTime < this.debounceMs) {
|
||||
// Too soon, skip this notification
|
||||
if (this.notifiedEvents.has(key)) {
|
||||
// Already sent this notification type for this task
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastNotificationTime.set(taskId, now);
|
||||
this.notifiedEvents.add(key);
|
||||
notifyFn().catch(() => {
|
||||
// Errors are logged in sendNotification, just need to catch here
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user