feat(FN-693): add projectId to NtfyNotifier for multi-project deep links
- Add projectId parameter to NtfyNotifier constructor and sendMessage method - Include projectId in notification deep links for multi-project context - Add unit tests for deep link URL construction with projectId in App.tsx - Add unit tests verifying project ID handling in notifier
This commit is contained in:
@@ -443,6 +443,152 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("project ID in URLs", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("includes projectId in Click URL when configured for in-review notifications", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj_123" });
|
||||
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": "http://localhost:3000/?project=proj_123&task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes projectId in Click URL when configured for failed task notifications", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store, { projectId: "my-project" });
|
||||
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/?project=my-project&task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes projectId in Click URL when configured for merged task notifications", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store, { projectId: "another-project" });
|
||||
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/?project=another-project&task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to task-only URL when projectId not configured", 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 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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes special characters in projectId", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store, { projectId: "proj/abc" });
|
||||
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": "http://localhost:3000/?project=proj%2Fabc&task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles projectId with spaces and special characters", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store, { projectId: "my project test" });
|
||||
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": "http://localhost:3000/?project=my%20project%20test&task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
|
||||
@@ -5,6 +5,8 @@ import { schedulerLog } from "./logger.js";
|
||||
export interface NtfyNotifierOptions {
|
||||
/** Base URL for ntfy.sh. Default: https://ntfy.sh */
|
||||
ntfyBaseUrl?: string;
|
||||
/** Project identifier for deep links in notifications */
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,8 @@ type NotificationEventType = "in-review" | "merged" | "failed";
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined };
|
||||
private ntfyBaseUrl: string;
|
||||
/** Project identifier for deep links in notifications */
|
||||
private projectId?: string;
|
||||
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
||||
private notifiedEvents: Set<string> = new Set();
|
||||
/** AbortController for in-flight requests during shutdown */
|
||||
@@ -63,6 +67,7 @@ export class NtfyNotifier {
|
||||
options: NtfyNotifierOptions = {},
|
||||
) {
|
||||
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
|
||||
this.projectId = options.projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +209,7 @@ export class NtfyNotifier {
|
||||
/**
|
||||
* Build a dashboard URL for deep linking to a task.
|
||||
* Returns undefined if dashboardHost is not configured.
|
||||
* Includes projectId in the URL when configured for multi-project support.
|
||||
*/
|
||||
private buildTaskUrl(taskId: string): string | undefined {
|
||||
if (!this.config.dashboardHost) {
|
||||
@@ -211,6 +217,9 @@ export class NtfyNotifier {
|
||||
}
|
||||
// Strip trailing slash from hostname if present
|
||||
const host = this.config.dashboardHost.replace(/\/$/, "");
|
||||
if (this.projectId) {
|
||||
return `${host}/?project=${encodeURIComponent(this.projectId)}&task=${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
return `${host}/?task=${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user