feat(FN-669): add ntfy deep link support with dashboard hostname config
- Add ntfyDashboardHost setting to GlobalSettings for dashboard URL\n- Generate deep links in ntfy notifications with Click header\n- Handle ?task= query param in dashboard for direct task navigation\n- Add hostname configuration UI in Settings modal\n- Add comprehensive tests for deep link generation and handling
This commit is contained in:
5
.changeset/ntfy-deep-link.md
Normal file
5
.changeset/ntfy-deep-link.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": minor
|
||||
---
|
||||
|
||||
Add deep link support to ntfy notifications. Notifications now include a Click URL that opens the dashboard directly to the task. New global setting "Dashboard Hostname" configures the base URL for deep links.
|
||||
@@ -525,6 +525,10 @@ export interface GlobalSettings {
|
||||
* no project can be auto-detected from the current directory.
|
||||
* Used for multi-project CLI workflows. */
|
||||
defaultProjectId?: string;
|
||||
/** Dashboard hostname for ntfy.sh deep links. When set along with ntfyEnabled
|
||||
* and ntfyTopic, notifications include a Click URL that opens the dashboard
|
||||
* directly to the task. Example: "http://localhost:3000" or "https://fusion.example.com" */
|
||||
ntfyDashboardHost?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -685,6 +689,7 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
defaultProjectId: undefined,
|
||||
ntfyDashboardHost: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -744,6 +749,7 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
"defaultProjectId",
|
||||
"ntfyDashboardHost",
|
||||
] as const;
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels } from "./api";
|
||||
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
|
||||
import type { ModelInfo } from "./api";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
@@ -109,6 +109,27 @@ function AppInner() {
|
||||
}, []);
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
|
||||
// Handle deep link to task on mount
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const taskId = params.get("task");
|
||||
if (!taskId) return;
|
||||
|
||||
// Clean URL immediately without reloading
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("task");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
|
||||
// Load and open the task directly
|
||||
fetchTaskDetail(taskId)
|
||||
.then((task) => {
|
||||
handleDetailOpen(task);
|
||||
})
|
||||
.catch(() => {
|
||||
addToast(`Task ${taskId} not found`, "error");
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Persist view preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("kb-dashboard-view", view);
|
||||
|
||||
@@ -1378,6 +1378,37 @@ export function SettingsModal({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{form.ntfyEnabled && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="ntfyDashboardHost">Dashboard Hostname</label>
|
||||
<input
|
||||
id="ntfyDashboardHost"
|
||||
type="text"
|
||||
placeholder="http://localhost:3000"
|
||||
value={form.ntfyDashboardHost || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined }));
|
||||
}}
|
||||
/>
|
||||
<small>
|
||||
Base URL for deep links in notifications. When set, clicking a notification
|
||||
will open the dashboard directly to the task. Example: http://localhost:3000
|
||||
or https://fusion.example.com
|
||||
</small>
|
||||
{form.ntfyDashboardHost && (
|
||||
!/^https?:\/\/.+/.test(form.ntfyDashboardHost) ? (
|
||||
<small className="field-error">
|
||||
Must be a valid URL starting with http:// or https://
|
||||
</small>
|
||||
) : form.ntfyDashboardHost.includes("?") || form.ntfyDashboardHost.includes("#") ? (
|
||||
<small className="field-error">
|
||||
URL should not include query parameters or fragments
|
||||
</small>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "authentication":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { App } from "../../App";
|
||||
import type { Settings } from "@fusion/core";
|
||||
@@ -35,6 +35,19 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve([])),
|
||||
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
|
||||
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: "Deep linked task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
log: [],
|
||||
prompt: "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,12 +62,85 @@ vi.mock("../../hooks/useTasks", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { fetchAuthStatus, fetchSettings, updateSettings } from "../../api";
|
||||
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings } from "../../api";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("App deep link handling", () => {
|
||||
const originalLocation = window.location;
|
||||
const originalReplaceState = window.history.replaceState;
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/"),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
window.history.replaceState = originalReplaceState;
|
||||
});
|
||||
|
||||
it("fetches and opens the task modal when task query param is present", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?task=FN-123"),
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-123");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Task FN-123")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
{},
|
||||
"",
|
||||
"http://localhost:3000/",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error toast when the deep-linked task cannot be loaded", async () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: new URL("http://localhost:3000/?task=FN-404"),
|
||||
});
|
||||
(fetchTaskDetail as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Not found"));
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-404");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Task FN-404 not found")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when no task query param is present", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||
expect(window.history.replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("App auto-open Settings on unauthenticated", () => {
|
||||
it("auto-opens Settings to Authentication tab when all providers are unauthenticated", async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -15,6 +15,12 @@ interface MockTaskStoreEvents {
|
||||
"settings:updated": [{ settings: Settings; previous: Settings }];
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
class MockTaskStore extends EventEmitter<MockTaskStoreEvents> {
|
||||
private settings: Settings = {
|
||||
maxConcurrent: 2,
|
||||
@@ -91,7 +97,7 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
// Wait for any async operations
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -103,7 +109,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -121,7 +127,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -143,7 +149,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-review", "done");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// handleTaskMoved should NOT send notification for "done" - that's handleTaskMerged's job
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -156,7 +162,7 @@ describe("NtfyNotifier", () => {
|
||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -185,7 +191,7 @@ describe("NtfyNotifier", () => {
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -215,7 +221,7 @@ describe("NtfyNotifier", () => {
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -227,7 +233,7 @@ describe("NtfyNotifier", () => {
|
||||
const taskWithoutTitle = { ...createTask("FN-001"), description: "Implement user authentication flow" };
|
||||
store.triggerTaskMoved(taskWithoutTitle, "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
@@ -245,7 +251,7 @@ describe("NtfyNotifier", () => {
|
||||
const taskWithoutTitle = { ...createTask("FN-001"), description: longDescription };
|
||||
store.triggerTaskMoved(taskWithoutTitle, "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
const expectedSnippet = "A".repeat(200) + "...";
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -264,7 +270,7 @@ describe("NtfyNotifier", () => {
|
||||
const taskWithoutTitle = { ...createTask("FN-001"), description: exactDescription };
|
||||
store.triggerTaskMoved(taskWithoutTitle, "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
@@ -275,6 +281,168 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deep link (Click header)", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("includes Click header with task URL when ntfyDashboardHost is set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include Click header when ntfyDashboardHost is not set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: undefined,
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
const callArgs = fetchMock.mock.calls[0][1];
|
||||
expect(callArgs.headers).not.toHaveProperty("Click");
|
||||
});
|
||||
|
||||
it("handles hostname with trailing slash correctly", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000/",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-042", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-042",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles hostname without trailing slash correctly", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-042", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-042",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Click header for failed task 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 failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Click header for merged task 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 mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Test Task"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes task IDs with special characters in Click URL", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001/test", "Test Task"), "in-progress", "in-review");
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001%2Ftest",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
@@ -283,7 +451,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// Initially disabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Enable at runtime
|
||||
@@ -291,7 +459,7 @@ describe("NtfyNotifier", () => {
|
||||
store.setSettings({ ntfyEnabled: true });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -303,14 +471,14 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// Initially enabled
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Disable at runtime
|
||||
store.setSettings({ ntfyEnabled: false });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // No new calls
|
||||
});
|
||||
|
||||
@@ -321,14 +489,14 @@ describe("NtfyNotifier", () => {
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://ntfy.sh/old-topic", expect.any(Object));
|
||||
|
||||
// Change topic
|
||||
store.setSettings({ ntfyTopic: "new-topic" });
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("https://ntfy.sh/new-topic", expect.any(Object));
|
||||
});
|
||||
});
|
||||
@@ -343,7 +511,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -357,7 +525,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// Should not throw
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -380,7 +548,7 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification due to deduplication
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -394,7 +562,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second: merged notification (different event type - should be allowed)
|
||||
@@ -406,7 +574,7 @@ describe("NtfyNotifier", () => {
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications now
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
@@ -429,7 +597,7 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskMoved(task, "in-review", "done");
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -463,7 +631,7 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should only send one notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -479,7 +647,7 @@ describe("NtfyNotifier", () => {
|
||||
store.triggerTaskMoved(task1, "in-progress", "in-review");
|
||||
store.triggerTaskMoved(task2, "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Different tasks should each get their own notification
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
@@ -496,7 +664,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://my-ntfy.example.com/test-topic",
|
||||
@@ -514,13 +682,13 @@ describe("NtfyNotifier", () => {
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
notifier.stop();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should not increase after stop
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -538,13 +706,13 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// First: in-review notification
|
||||
store.triggerTaskMoved(task, "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
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));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Should have two notifications
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
@@ -558,15 +726,15 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
// Move to todo - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "triage", "todo");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to in-progress - should not notify
|
||||
store.triggerTaskMoved(createTask("FN-002", "Test Task 2"), "todo", "in-progress");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Move to done - should not notify (merged notification comes from task:merged)
|
||||
store.triggerTaskMoved(createTask("FN-003", "Test Task 3"), "in-review", "done");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -579,7 +747,7 @@ describe("NtfyNotifier", () => {
|
||||
|
||||
const task = createTask("FN-001", "Test Task", "in-progress");
|
||||
store.triggerTaskUpdated(task);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -591,7 +759,7 @@ describe("NtfyNotifier", () => {
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await flushAsyncWork();
|
||||
|
||||
// Empty topic should be treated as no topic
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
@@ -33,6 +33,7 @@ interface NtfyNotifierStore {
|
||||
interface NtfyConfig {
|
||||
enabled: boolean;
|
||||
topic: string | undefined;
|
||||
dashboardHost: string | undefined;
|
||||
}
|
||||
|
||||
/** Event types for notification deduplication */
|
||||
@@ -50,7 +51,7 @@ type NotificationEventType = "in-review" | "merged" | "failed";
|
||||
* - Configurable notification events (hardcoded defaults)
|
||||
*/
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined };
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined };
|
||||
private ntfyBaseUrl: string;
|
||||
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
||||
private notifiedEvents: Set<string> = new Set();
|
||||
@@ -116,12 +117,14 @@ export class NtfyNotifier {
|
||||
|
||||
// Notify when task moves to in-review (completed work, ready for review)
|
||||
if (to === "in-review") {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
this.maybeNotify(task.id, "in-review", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} completed`,
|
||||
`Task "${formatTaskIdentifier(task)}" is ready for review`,
|
||||
"default",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -135,12 +138,14 @@ export class NtfyNotifier {
|
||||
|
||||
// Notify when task fails
|
||||
if (task.status === "failed") {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
this.maybeNotify(task.id, "failed", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} failed`,
|
||||
`Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
|
||||
"high",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -151,12 +156,14 @@ export class NtfyNotifier {
|
||||
|
||||
// Only notify on successful merges
|
||||
if (result.merged) {
|
||||
const clickUrl = this.buildTaskUrl(result.task.id);
|
||||
this.maybeNotify(result.task.id, "merged", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${result.task.id} merged`,
|
||||
`Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
|
||||
"default",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -167,7 +174,8 @@ export class NtfyNotifier {
|
||||
|
||||
// Check if ntfy settings changed
|
||||
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
settings.ntfyTopic !== previous.ntfyTopic) {
|
||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
|
||||
const wasEnabled = this.config.enabled;
|
||||
this.loadConfig(settings);
|
||||
|
||||
@@ -177,6 +185,8 @@ export class NtfyNotifier {
|
||||
schedulerLog.log("NtfyNotifier disabled");
|
||||
} else if (this.config.topic !== previous.ntfyTopic) {
|
||||
schedulerLog.log("NtfyNotifier topic updated");
|
||||
} else if (this.config.dashboardHost !== previous.ntfyDashboardHost) {
|
||||
schedulerLog.log("NtfyNotifier dashboard host updated");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -185,9 +195,23 @@ export class NtfyNotifier {
|
||||
this.config = {
|
||||
enabled: settings.ntfyEnabled ?? false,
|
||||
topic: settings.ntfyTopic,
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dashboard URL for deep linking to a task.
|
||||
* Returns undefined if dashboardHost is not configured.
|
||||
*/
|
||||
private buildTaskUrl(taskId: string): string | undefined {
|
||||
if (!this.config.dashboardHost) {
|
||||
return undefined;
|
||||
}
|
||||
// Strip trailing slash from hostname if present
|
||||
const host = this.config.dashboardHost.replace(/\/$/, "");
|
||||
return `${host}/?task=${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification if this (taskId, eventType) pair hasn't been notified before.
|
||||
* This prevents duplicate notifications for the same event type per task.
|
||||
@@ -219,18 +243,26 @@ export class NtfyNotifier {
|
||||
title: string,
|
||||
message: string,
|
||||
priority: "low" | "default" | "high" | "urgent" = "default",
|
||||
clickUrl?: string,
|
||||
): Promise<void> {
|
||||
const url = `${this.ntfyBaseUrl}/${topic}`;
|
||||
const signal = this.abortController?.signal;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
|
||||
// Add Click header for deep linking if URL is provided
|
||||
if (clickUrl) {
|
||||
headers["Click"] = clickUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
headers,
|
||||
body: message,
|
||||
signal,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user