feat(FN-1562): merge fusion/fn-1562
This commit is contained in:
@@ -201,6 +201,29 @@ describe("NtfyNotifier", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends high priority notification when task needs user review", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-003", "Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Title": "User review needed for FN-003",
|
||||
"Priority": "high",
|
||||
}),
|
||||
body: 'Task "Review Task" needs human review before it can proceed',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends notification when task is merged", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
@@ -437,6 +460,29 @@ describe("NtfyNotifier", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Click header for awaiting-user-review notifications when dashboard host is set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-003", "Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-003",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Click header for merged task notifications when dashboard host is set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
@@ -568,6 +614,29 @@ describe("NtfyNotifier", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes projectId in Click URL when configured for awaiting-user-review notifications", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store, { projectId: "user-review-project" });
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-001", "Test Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?project=user-review-project&task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to task-only URL when projectId not configured", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
@@ -769,6 +838,29 @@ describe("NtfyNotifier", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("prevents duplicate awaiting-user-review notifications for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const task = createTask("FN-005", "User Review Task", "awaiting-user-review");
|
||||
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
store.triggerTaskUpdated(task);
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Title": "User review needed for FN-005",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows different event types for the same task", async () => {
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
@@ -1152,6 +1244,18 @@ describe("NtfyNotifier", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send awaiting-user-review notification when 'awaiting-user-review' is not in ntfyEvents", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-007", "User Review Task", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends notification for enabled events while others are disabled", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
@@ -1185,6 +1289,12 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
|
||||
// awaiting-user-review - should NOT send
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // Still 1, no new call
|
||||
});
|
||||
|
||||
it("defaults to all events when ntfyEvents is undefined", async () => {
|
||||
@@ -1216,10 +1326,15 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskUpdated(awaitingApprovalTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
const awaitingUserReviewTask = createTask("FN-005", "Test Task 5", "awaiting-user-review");
|
||||
store.triggerTaskUpdated(awaitingUserReviewTask);
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("updates notifications when ntfyEvents changes at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
@@ -1229,14 +1344,14 @@ describe("NtfyNotifier", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable in-review
|
||||
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval"] });
|
||||
store.setSettings({ ntfyEvents: ["merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new call for in-review
|
||||
|
||||
// Enable in-review again
|
||||
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"] });
|
||||
store.setSettings({ ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
@@ -40,7 +40,7 @@ interface NtfyConfig {
|
||||
}
|
||||
|
||||
/** Event types for notification deduplication */
|
||||
type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-approval";
|
||||
type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-approval" | "awaiting-user-review";
|
||||
|
||||
/**
|
||||
* NtfyNotifier sends push notifications via ntfy.sh when tasks complete
|
||||
@@ -54,7 +54,7 @@ type NotificationEventType = "in-review" | "merged" | "failed" | "awaiting-appro
|
||||
* - Configurable notification events (hardcoded defaults)
|
||||
*/
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined, events: ["in-review", "merged", "failed", "awaiting-approval"] };
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined, events: ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"] };
|
||||
private ntfyBaseUrl: string;
|
||||
/** Project identifier for deep links in notifications */
|
||||
private projectId?: string;
|
||||
@@ -171,6 +171,20 @@ export class NtfyNotifier {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Notify when task needs human review (agent handoff to user)
|
||||
if (task.status === "awaiting-user-review" && this.isEventEnabled("awaiting-user-review")) {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
this.maybeNotify(task.id, "awaiting-user-review", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`User review needed for ${task.id}`,
|
||||
`Task "${formatTaskIdentifier(task)}" needs human review before it can proceed`,
|
||||
"high",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private handleTaskMerged = (result: MergeResult): void => {
|
||||
@@ -221,7 +235,7 @@ export class NtfyNotifier {
|
||||
enabled: settings.ntfyEnabled ?? false,
|
||||
topic: settings.ntfyTopic,
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
events: settings.ntfyEvents ?? ["in-review", "merged", "failed", "awaiting-approval"],
|
||||
events: settings.ntfyEvents ?? ["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user