mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
Improve auto-loop event emission and add ntfy notifications (#821)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -71,7 +71,7 @@ test/agent-session-test-*/
|
||||
test/feature-backlog-test-*/
|
||||
test/running-task-display-test-*/
|
||||
test/agent-output-modal-responsive-*/
|
||||
test/fixtures/.worker-*/
|
||||
test/fixtures/
|
||||
test/board-bg-test-*/
|
||||
test/edit-feature-test-*/
|
||||
test/open-project-test-*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.15.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||
const FAILURE_WINDOW_MS = 60000;
|
||||
|
||||
// Sleep intervals for the auto-loop (in milliseconds)
|
||||
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
|
||||
const SLEEP_INTERVAL_IDLE_MS = 10000;
|
||||
const SLEEP_INTERVAL_NORMAL_MS = 2000;
|
||||
const SLEEP_INTERVAL_ERROR_MS = 5000;
|
||||
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
@@ -169,20 +175,32 @@ export class AutoLoopCoordinator {
|
||||
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
if (runningCount >= projectState.config.maxConcurrency) {
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||
if (pendingFeatures.length === 0) {
|
||||
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
// Double-check that we have no features in 'in_progress' state that might
|
||||
// have been released from the concurrency manager but not yet updated to
|
||||
// their final status. This prevents auto_mode_idle from firing prematurely
|
||||
// when features are transitioning states (e.g., during status update).
|
||||
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
branchName
|
||||
);
|
||||
|
||||
// Only emit auto_mode_idle if we're truly done with all features
|
||||
if (!hasInProgressFeatures) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
}
|
||||
}
|
||||
await this.sleep(10000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.sleep(2000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
|
||||
} catch {
|
||||
if (projectState.abortController.signal.aborted) break;
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
|
||||
}
|
||||
}
|
||||
projectState.isRunning = false;
|
||||
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature belongs to the current worktree based on branch name.
|
||||
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
|
||||
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
|
||||
*/
|
||||
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
|
||||
const isMainWorktree = branchName === null || branchName === 'main';
|
||||
if (isMainWorktree) {
|
||||
// Main worktree: include features with no branchName or branchName === 'main'
|
||||
return !feature.branchName || feature.branchName === 'main';
|
||||
} else {
|
||||
// Feature worktree: only include exact branch match
|
||||
return feature.branchName === branchName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are features in 'in_progress' status for the current worktree.
|
||||
* This prevents auto_mode_idle from firing prematurely when features are
|
||||
* transitioning states (e.g., during status update from in_progress to completed).
|
||||
*/
|
||||
private async hasInProgressFeaturesForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<boolean> {
|
||||
if (!this.loadAllFeaturesFn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFeatures = await this.loadAllFeaturesFn(projectPath);
|
||||
return allFeatures.some(
|
||||
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
|
||||
);
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
logger.warn(
|
||||
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ import type {
|
||||
EventHookTrigger,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
NtfyEndpointConfig,
|
||||
EventHookContext,
|
||||
} from '@automaker/types';
|
||||
import { ntfyService, type NtfyContext } from './ntfy-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('EventHooks');
|
||||
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Context available for variable substitution in hooks
|
||||
*/
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: EventHookTrigger;
|
||||
}
|
||||
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
|
||||
type HookContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
@@ -451,6 +444,8 @@ export class EventHookService {
|
||||
await this.executeShellHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'http') {
|
||||
await this.executeHttpHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'ntfy') {
|
||||
await this.executeNtfyHook(hook.action, context, hookName);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Hook "${hookName}" failed:`, error);
|
||||
@@ -558,6 +553,89 @@ export class EventHookService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ntfy.sh notification hook
|
||||
*/
|
||||
private async executeNtfyHook(
|
||||
action: EventHookNtfyAction,
|
||||
context: HookContext,
|
||||
hookName: string
|
||||
): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('Settings service not available for ntfy hook');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the endpoint configuration
|
||||
const settings = await this.settingsService.getGlobalSettings();
|
||||
const endpoints = settings.ntfyEndpoints || [];
|
||||
const endpoint = endpoints.find((e) => e.id === action.endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert HookContext to NtfyContext
|
||||
const ntfyContext: NtfyContext = {
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
errorType: context.errorType,
|
||||
timestamp: context.timestamp,
|
||||
eventType: context.eventType,
|
||||
};
|
||||
|
||||
// Build click URL with deep-link if project context is available
|
||||
let clickUrl = action.clickUrl;
|
||||
if (!clickUrl && endpoint.defaultClickUrl) {
|
||||
clickUrl = endpoint.defaultClickUrl;
|
||||
// If we have a project path and the click URL looks like the server URL,
|
||||
// append deep-link path
|
||||
if (context.projectPath && clickUrl) {
|
||||
try {
|
||||
const url = new URL(clickUrl);
|
||||
// Add featureId as query param for deep linking to board with feature output modal
|
||||
if (context.featureId) {
|
||||
url.pathname = '/board';
|
||||
url.searchParams.set('featureId', context.featureId);
|
||||
} else if (context.projectPath) {
|
||||
url.pathname = '/board';
|
||||
}
|
||||
clickUrl = url.toString();
|
||||
} catch (error) {
|
||||
// If URL parsing fails, log warning and use as-is
|
||||
logger.warn(
|
||||
`Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
|
||||
|
||||
const result = await ntfyService.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: action.title,
|
||||
body: action.body,
|
||||
tags: action.tags,
|
||||
emoji: action.emoji,
|
||||
clickUrl,
|
||||
priority: action.priority,
|
||||
},
|
||||
ntfyContext
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
|
||||
} else {
|
||||
logger.info(`Ntfy hook "${hookName}" completed successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
|
||||
@@ -33,6 +33,36 @@ import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
const logger = createLogger('FeatureStateManager');
|
||||
|
||||
// Notification type constants
|
||||
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
|
||||
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
|
||||
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
|
||||
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
|
||||
|
||||
// Notification title constants
|
||||
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
|
||||
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
|
||||
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
|
||||
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
* This is the payload that comes with 'auto-mode:event' events
|
||||
*/
|
||||
interface AutoModeEventPayload {
|
||||
type?: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
executionMode?: 'auto' | 'manual';
|
||||
message?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
projectPath?: string;
|
||||
/** Status field present when type === 'feature_status_changed' */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureStateManager handles feature status updates with persistence guarantees.
|
||||
*
|
||||
@@ -45,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
|
||||
export class FeatureStateManager {
|
||||
private events: EventEmitter;
|
||||
private featureLoader: FeatureLoader;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
||||
this.events = events;
|
||||
this.featureLoader = featureLoader;
|
||||
|
||||
// Subscribe to error events to create notifications
|
||||
this.unsubscribe = events.subscribe((type, payload) => {
|
||||
if (type === 'auto-mode:event') {
|
||||
this.handleAutoModeEventError(payload as AutoModeEventPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup subscriptions
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,77 +154,18 @@ export class FeatureStateManager {
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === 'waiting_approval') {
|
||||
// Handle justFinishedAt timestamp based on status
|
||||
const shouldSetJustFinishedAt = status === 'waiting_approval';
|
||||
const shouldClearJustFinishedAt = status !== 'waiting_approval';
|
||||
if (shouldSetJustFinishedAt) {
|
||||
feature.justFinishedAt = new Date().toISOString();
|
||||
} else if (shouldClearJustFinishedAt) {
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
|
||||
// Finalize task statuses when feature is done:
|
||||
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
||||
// - Do NOT mark pending tasks as completed (they were never started)
|
||||
// - Clear currentTaskId since no task is actively running
|
||||
// This prevents cards in "waiting for review" from appearing to still have running tasks
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
} else if (status === 'verified') {
|
||||
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
|
||||
// Do NOT mark pending tasks as completed - they were never started
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
} else {
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
|
||||
if (status === 'waiting_approval' || status === 'verified') {
|
||||
this.finalizeInProgressTasks(feature, featureId, status);
|
||||
}
|
||||
|
||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||
@@ -193,19 +182,21 @@ export class FeatureStateManager {
|
||||
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
const displayName = this.getFeatureDisplayName(feature, featureId);
|
||||
|
||||
if (status === 'waiting_approval') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'Feature Ready for Review',
|
||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
||||
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
} else if (status === 'verified') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_verified',
|
||||
title: 'Feature Verified',
|
||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
||||
type: NOTIFICATION_TYPE_VERIFIED,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_VERIFIED,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
@@ -736,6 +727,137 @@ export class FeatureStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a feature, preferring title over feature ID.
|
||||
* Empty string titles are treated as missing and fallback to featureId.
|
||||
*
|
||||
* @param feature - The feature to get the display name for
|
||||
* @param featureId - The feature ID to use as fallback
|
||||
* @returns The display name (title or feature ID)
|
||||
*/
|
||||
private getFeatureDisplayName(feature: Feature, featureId: string): string {
|
||||
// Use title if it's a non-empty string, otherwise fallback to featureId
|
||||
return feature.title && feature.title.trim() ? feature.title : featureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-mode events to create error notifications.
|
||||
* This listens for error events and creates notifications to alert users.
|
||||
*/
|
||||
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
|
||||
if (!payload.type) return;
|
||||
|
||||
// Only handle error events
|
||||
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// For auto_mode_feature_complete, only notify on failures (passes === false)
|
||||
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get project path - handle different event formats
|
||||
const projectPath = payload.projectPath;
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Determine notification type and title based on event type
|
||||
// Only auto_mode_feature_complete events should create feature_error notifications
|
||||
const isFeatureError = payload.type === 'auto_mode_feature_complete';
|
||||
const notificationType = isFeatureError
|
||||
? NOTIFICATION_TYPE_FEATURE_ERROR
|
||||
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
|
||||
const notificationTitle = isFeatureError
|
||||
? NOTIFICATION_TITLE_FEATURE_ERROR
|
||||
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
|
||||
|
||||
// Build error message
|
||||
let errorMessage = payload.message || 'An error occurred';
|
||||
if (payload.error) {
|
||||
errorMessage = payload.error;
|
||||
}
|
||||
|
||||
// Use feature title as notification title when available, fall back to gesture name
|
||||
let title = notificationTitle;
|
||||
if (payload.featureId) {
|
||||
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
|
||||
if (displayName) {
|
||||
title = displayName;
|
||||
errorMessage = `${notificationTitle}: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
await notificationService.createNotification({
|
||||
type: notificationType,
|
||||
title,
|
||||
message: errorMessage,
|
||||
featureId: payload.featureId,
|
||||
projectPath,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
logger.warn(`Failed to create error notification:`, notificationError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature display name by loading the feature directly.
|
||||
*/
|
||||
private async getFeatureDisplayNameById(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) return null;
|
||||
return this.getFeatureDisplayName(feature, featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-progress tasks when a feature reaches a terminal state.
|
||||
* Marks in_progress tasks as completed but leaves pending tasks untouched.
|
||||
*
|
||||
* @param feature - The feature whose tasks should be finalized
|
||||
* @param featureId - The feature ID for logging
|
||||
* @param targetStatus - The status the feature is transitioning to
|
||||
*/
|
||||
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
|
||||
if (!feature.planSpec?.tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event via the event emitter
|
||||
*
|
||||
|
||||
282
apps/server/src/services/ntfy-service.ts
Normal file
282
apps/server/src/services/ntfy-service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Ntfy Service - Sends push notifications via ntfy.sh
|
||||
*
|
||||
* Provides integration with ntfy.sh for push notifications.
|
||||
* Supports custom servers, authentication, tags, emojis, and click actions.
|
||||
*
|
||||
* @see https://docs.ntfy.sh/publish/
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('Ntfy');
|
||||
|
||||
/** Default timeout for ntfy HTTP requests (10 seconds) */
|
||||
const DEFAULT_NTFY_TIMEOUT = 10000;
|
||||
|
||||
// Re-export EventHookContext as NtfyContext for backward compatibility
|
||||
export type NtfyContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Ntfy Service
|
||||
*
|
||||
* Handles sending notifications to ntfy.sh endpoints.
|
||||
*/
|
||||
export class NtfyService {
|
||||
/**
|
||||
* Send a notification to a ntfy.sh endpoint
|
||||
*
|
||||
* @param endpoint The ntfy.sh endpoint configuration
|
||||
* @param options Notification options (title, body, tags, etc.)
|
||||
* @param context Context for variable substitution
|
||||
*/
|
||||
async sendNotification(
|
||||
endpoint: NtfyEndpointConfig,
|
||||
options: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tags?: string;
|
||||
emoji?: string;
|
||||
clickUrl?: string;
|
||||
priority?: 1 | 2 | 3 | 4 | 5;
|
||||
},
|
||||
context: NtfyContext
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!endpoint.enabled) {
|
||||
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
|
||||
return { success: false, error: 'Endpoint is disabled' };
|
||||
}
|
||||
|
||||
// Validate endpoint configuration
|
||||
const validationError = this.validateEndpoint(endpoint);
|
||||
if (validationError) {
|
||||
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
|
||||
return { success: false, error: validationError };
|
||||
}
|
||||
|
||||
// Build URL
|
||||
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
// Title (with variable substitution)
|
||||
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
|
||||
if (title) {
|
||||
headers['Title'] = title;
|
||||
}
|
||||
|
||||
// Priority
|
||||
const priority = options.priority || 3;
|
||||
headers['Priority'] = String(priority);
|
||||
|
||||
// Tags and emoji
|
||||
const tags = this.buildTags(
|
||||
options.tags || endpoint.defaultTags,
|
||||
options.emoji || endpoint.defaultEmoji
|
||||
);
|
||||
if (tags) {
|
||||
headers['Tags'] = tags;
|
||||
}
|
||||
|
||||
// Click action URL
|
||||
const clickUrl = this.substituteVariables(
|
||||
options.clickUrl || endpoint.defaultClickUrl || '',
|
||||
context
|
||||
);
|
||||
if (clickUrl) {
|
||||
headers['Click'] = clickUrl;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
this.addAuthHeaders(headers, endpoint);
|
||||
|
||||
// Message body (with variable substitution)
|
||||
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
|
||||
|
||||
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
|
||||
return { success: false, error: 'Request timed out' };
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ntfy notification failed: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an ntfy endpoint configuration
|
||||
*/
|
||||
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
|
||||
// Validate server URL
|
||||
if (!endpoint.serverUrl) {
|
||||
return 'Server URL is required';
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(endpoint.serverUrl);
|
||||
} catch {
|
||||
return 'Invalid server URL format';
|
||||
}
|
||||
|
||||
// Validate topic
|
||||
if (!endpoint.topic) {
|
||||
return 'Topic is required';
|
||||
}
|
||||
|
||||
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
|
||||
return 'Topic cannot contain spaces';
|
||||
}
|
||||
|
||||
// Validate authentication
|
||||
if (endpoint.authType === 'basic') {
|
||||
if (!endpoint.username || !endpoint.password) {
|
||||
return 'Username and password are required for basic authentication';
|
||||
}
|
||||
} else if (endpoint.authType === 'token') {
|
||||
if (!endpoint.token) {
|
||||
return 'Access token is required for token authentication';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tags string from tags and emoji
|
||||
*/
|
||||
private buildTags(tags?: string, emoji?: string): string {
|
||||
const tagList: string[] = [];
|
||||
|
||||
if (tags) {
|
||||
// Split by comma and trim whitespace
|
||||
const parsedTags = tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
tagList.push(...parsedTags);
|
||||
}
|
||||
|
||||
if (emoji) {
|
||||
// Add emoji as first tag if it looks like a shortcode
|
||||
if (emoji.startsWith(':') && emoji.endsWith(':')) {
|
||||
tagList.unshift(emoji.slice(1, -1));
|
||||
} else if (!emoji.includes(' ')) {
|
||||
// If it's a single emoji or shortcode without colons, add as-is
|
||||
tagList.unshift(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return tagList.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add authentication headers based on auth type
|
||||
*/
|
||||
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
|
||||
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
|
||||
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
|
||||
'base64'
|
||||
);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
} else if (endpoint.authType === 'token' && endpoint.token) {
|
||||
headers['Authorization'] = `Bearer ${endpoint.token}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default title based on event context
|
||||
*/
|
||||
private getDefaultTitle(context: NtfyContext): string {
|
||||
const eventName = this.formatEventName(context.eventType);
|
||||
if (context.featureName) {
|
||||
return `${eventName}: ${context.featureName}`;
|
||||
}
|
||||
return eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default body based on event context
|
||||
*/
|
||||
private getDefaultBody(context: NtfyContext): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.featureName) {
|
||||
lines.push(`Feature: ${context.featureName}`);
|
||||
}
|
||||
if (context.featureId) {
|
||||
lines.push(`ID: ${context.featureId}`);
|
||||
}
|
||||
if (context.projectName) {
|
||||
lines.push(`Project: ${context.projectName}`);
|
||||
}
|
||||
if (context.error) {
|
||||
lines.push(`Error: ${context.error}`);
|
||||
}
|
||||
lines.push(`Time: ${context.timestamp}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event type to human-readable name
|
||||
*/
|
||||
private formatEventName(eventType: string): string {
|
||||
const eventNames: Record<string, string> = {
|
||||
feature_created: 'Feature Created',
|
||||
feature_success: 'Feature Completed',
|
||||
feature_error: 'Feature Failed',
|
||||
auto_mode_complete: 'Auto Mode Complete',
|
||||
auto_mode_error: 'Auto Mode Error',
|
||||
};
|
||||
return eventNames[eventType] || eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
private substituteVariables(template: string, context: NtfyContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof NtfyContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const ntfyService = new NtfyService();
|
||||
@@ -618,6 +618,36 @@ export class SettingsService {
|
||||
ignoreEmptyArrayOverwrite('eventHooks');
|
||||
}
|
||||
|
||||
// Guard ntfyEndpoints against accidental wipe
|
||||
// (similar to eventHooks, these are user-configured and shouldn't be lost)
|
||||
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
|
||||
const allowEmptyNtfyEndpoints =
|
||||
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
|
||||
// Remove the flag so it doesn't get persisted
|
||||
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
|
||||
|
||||
if (!allowEmptyNtfyEndpoints) {
|
||||
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
|
||||
? current.ntfyEndpoints.length
|
||||
: 0;
|
||||
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
|
||||
? sanitizedUpdates.ntfyEndpoints.length
|
||||
: currentNtfyLen;
|
||||
|
||||
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
|
||||
logger.warn(
|
||||
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
|
||||
{
|
||||
currentNtfyLen,
|
||||
newNtfyLen,
|
||||
}
|
||||
);
|
||||
delete sanitizedUpdates.ntfyEndpoints;
|
||||
}
|
||||
} else {
|
||||
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
|
||||
}
|
||||
|
||||
// Empty object overwrite guard
|
||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
@@ -1023,6 +1053,8 @@ export class SettingsService {
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
|
||||
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
|
||||
@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/home/user/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Implement login feature',
|
||||
description: 'Add user authentication with OAuth',
|
||||
},
|
||||
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/home/user/other-project',
|
||||
projectName: 'other-project',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Fix navigation bug',
|
||||
description: undefined,
|
||||
},
|
||||
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: `/project-${i}`,
|
||||
projectName: `project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
|
||||
provider: 'claude',
|
||||
title: `Feature ${i}`,
|
||||
description: `Description ${i}`,
|
||||
}));
|
||||
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/workspace/project-alpha',
|
||||
projectName: 'project-alpha',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Feature A',
|
||||
description: 'In project alpha',
|
||||
},
|
||||
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/workspace/project-beta',
|
||||
projectName: 'project-beta',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Feature B',
|
||||
description: 'In project beta',
|
||||
},
|
||||
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
|
||||
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
||||
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
|
||||
});
|
||||
|
||||
it('should include model and provider information for running agents', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-claude',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Claude Feature',
|
||||
description: 'Using Claude model',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-codex',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Codex Feature',
|
||||
description: 'Using Codex model',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-cursor',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: false,
|
||||
model: 'cursor-auto',
|
||||
provider: 'cursor',
|
||||
title: 'Cursor Feature',
|
||||
description: 'Using Cursor model',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
const response = vi.mocked(res.json).mock.calls[0][0];
|
||||
expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514');
|
||||
expect(response.runningAgents[0].provider).toBe('claude');
|
||||
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
|
||||
expect(response.runningAgents[1].provider).toBe('codex');
|
||||
expect(response.runningAgents[2].model).toBe('cursor-auto');
|
||||
expect(response.runningAgents[2].provider).toBe('cursor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_idle emission timing (idle check fix)', () => {
|
||||
it('emits auto_mode_idle when no features in any state (empty project)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration and idle event
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle when features are in in_progress status', async () => {
|
||||
// No pending features (backlog/ready)
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// But there are features in in_progress status
|
||||
const inProgressFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'in_progress',
|
||||
title: 'In Progress Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]);
|
||||
// No running features in concurrency manager (they were released during status update)
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle after in_progress feature completes', async () => {
|
||||
const completedFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'completed',
|
||||
title: 'Completed Feature',
|
||||
};
|
||||
|
||||
// Initially has in_progress feature
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should emit auto_mode_idle because all features are completed
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Feature in main worktree has no branchName
|
||||
const mainWorktreeFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-main',
|
||||
status: 'in_progress',
|
||||
title: 'Main Worktree Feature',
|
||||
branchName: undefined, // Main worktree feature
|
||||
};
|
||||
// Feature in branch worktree has branchName
|
||||
const branchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-branch',
|
||||
status: 'in_progress',
|
||||
title: 'Branch Feature',
|
||||
branchName: 'feature/some-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for main worktree
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
|
||||
'auto_mode_idle',
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Feature in matching branch
|
||||
const matchingBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-matching',
|
||||
status: 'in_progress',
|
||||
title: 'Matching Branch Feature',
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
// Feature in different branch
|
||||
const differentBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-different',
|
||||
status: 'in_progress',
|
||||
title: 'Different Branch Feature',
|
||||
branchName: 'feature/other-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
|
||||
matchingBranchFeature,
|
||||
differentBranchFeature,
|
||||
]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for feature/test-branch
|
||||
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
|
||||
'auto_mode_idle',
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
branchName: 'feature/test-branch',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle when in_progress feature has different branchName', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Only feature is in a different branch
|
||||
const differentBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-different',
|
||||
status: 'in_progress',
|
||||
title: 'Different Branch Feature',
|
||||
branchName: 'feature/other-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for feature/test-branch
|
||||
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
|
||||
|
||||
// Should emit auto_mode_idle because the in_progress feature is in a different branch
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: 'feature/test-branch',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => {
|
||||
// backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check
|
||||
// But this test verifies the idle check doesn't incorrectly block on backlog/ready
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check)
|
||||
const backlogFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'backlog',
|
||||
title: 'Backlog Feature',
|
||||
};
|
||||
const readyFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-2',
|
||||
status: 'ready',
|
||||
title: 'Ready Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there are backlog/ready features
|
||||
// (even though they're not in_progress, the idle check only looks at in_progress status)
|
||||
// Actually, backlog/ready would be caught by loadPendingFeatures on next iteration,
|
||||
// so this should emit idle since runningCount=0 and no in_progress features
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features'));
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior)
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => {
|
||||
// Create coordinator without loadAllFeaturesFn
|
||||
const coordWithoutLoadAll = new AutoLoopCoordinator(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockSettingsService,
|
||||
mockExecuteFeature,
|
||||
mockLoadPendingFeatures,
|
||||
mockSaveExecutionState,
|
||||
mockClearExecutionState,
|
||||
mockResetStuckFeatures,
|
||||
mockIsFeatureFinished,
|
||||
mockIsFeatureRunning
|
||||
// loadAllFeaturesFn omitted
|
||||
);
|
||||
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior)
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time multiple times to trigger multiple loop iterations
|
||||
await vi.advanceTimersByTimeAsync(11000); // First idle check
|
||||
await vi.advanceTimersByTimeAsync(11000); // Second idle check
|
||||
await vi.advanceTimersByTimeAsync(11000); // Third idle check
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should only emit auto_mode_idle once despite multiple iterations
|
||||
const idleCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_idle');
|
||||
expect(idleCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => {
|
||||
// This test reproduces the exact bug scenario described in the feature:
|
||||
// When a feature completes, there's a brief window where:
|
||||
// 1. The feature has been released from runningFeatures (so runningCount = 0)
|
||||
// 2. The feature's status is still 'in_progress' during the status update transition
|
||||
// 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses)
|
||||
// The fix ensures auto_mode_idle is NOT emitted in this window
|
||||
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features
|
||||
// Feature is still in in_progress status (during status update transition)
|
||||
const transitioningFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'in_progress',
|
||||
title: 'Transitioning Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]);
|
||||
// Feature has been released from concurrency manager (runningCount = 0)
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// The fix prevents auto_mode_idle from being emitted in this scenario
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('AutoModeServiceFacade Agent Runner', () => {
|
||||
|
||||
// Helper to access the private createRunAgentFn via factory creation
|
||||
facade = AutoModeServiceFacade.create('/project', {
|
||||
events: { on: vi.fn(), emit: vi.fn() } as any,
|
||||
events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any,
|
||||
settingsService: mockSettingsService,
|
||||
sharedServices: {
|
||||
eventBus: { emitAutoModeEvent: vi.fn() } as any,
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js'
|
||||
import type { EventHistoryService } from '../../../src/services/event-history-service.js';
|
||||
import type { FeatureLoader } from '../../../src/services/feature-loader.js';
|
||||
|
||||
// Mock global fetch for ntfy tests
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
/**
|
||||
* Create a mock EventEmitter for testing
|
||||
*/
|
||||
@@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & {
|
||||
/**
|
||||
* Create a mock SettingsService
|
||||
*/
|
||||
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
|
||||
function createMockSettingsService(
|
||||
hooks: unknown[] = [],
|
||||
ntfyEndpoints: unknown[] = []
|
||||
): SettingsService {
|
||||
return {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
eventHooks: hooks,
|
||||
ntfyEndpoints: ntfyEndpoints,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
}
|
||||
|
||||
@@ -70,6 +79,7 @@ describe('EventHookService', () => {
|
||||
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
|
||||
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
|
||||
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new EventHookService();
|
||||
@@ -77,10 +87,14 @@ describe('EventHookService', () => {
|
||||
mockSettingsService = createMockSettingsService();
|
||||
mockEventHistoryService = createMockEventHistoryService();
|
||||
mockFeatureLoader = createMockFeatureLoader();
|
||||
// Set up mock fetch for ntfy tests
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.destroy();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
@@ -832,4 +846,628 @@ describe('EventHookService', () => {
|
||||
expect(storeCall.error).toBe('Feature stopped by user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ntfy hook execution', () => {
|
||||
const mockNtfyEndpoint = {
|
||||
id: 'endpoint-1',
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none' as const,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it('should execute ntfy hook when endpoint is configured', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Success Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
title: 'Feature {{featureName}} completed!',
|
||||
priority: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['Title']).toBe('Feature Test Feature completed!');
|
||||
});
|
||||
|
||||
it('should NOT execute ntfy hook when endpoint is not found', async () => {
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Missing Endpoint',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'non-existent-endpoint',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fetch should NOT have been called since endpoint doesn't exist
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use ntfy endpoint default values when hook does not override', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaults = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'tada',
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_error',
|
||||
name: 'Ntfy Error Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
// No title, tags, or emoji - should use endpoint defaults
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Failed Feature',
|
||||
passes: false,
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Should use default tags and emoji from endpoint
|
||||
expect(options.headers['Tags']).toBe('tada,default-tag');
|
||||
// Click URL gets deep-link query param when feature context is available
|
||||
expect(options.headers['Click']).toContain('https://default.example.com/board');
|
||||
expect(options.headers['Click']).toContain('featureId=feat-1');
|
||||
});
|
||||
|
||||
it('should send ntfy notification with authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithAuth = {
|
||||
...mockNtfyEndpoint,
|
||||
authType: 'token' as const,
|
||||
token: 'tk_test_token',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Authenticated Ntfy Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Authorization']).toBe('Bearer tk_test_token');
|
||||
});
|
||||
|
||||
it('should handle ntfy notification failure gracefully', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook That Will Fail',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
// Should not throw - error should be caught gracefully
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Event should still be stored even if ntfy hook fails
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should substitute variables in ntfy title and body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Variables',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
title: '[{{projectName}}] {{featureName}}',
|
||||
body: 'Feature {{featureId}} completed at {{timestamp}}',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-123',
|
||||
featureName: 'Cool Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/my-project',
|
||||
projectName: 'my-project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[my-project] Cool Feature');
|
||||
expect(options.body).toContain('feat-123');
|
||||
});
|
||||
|
||||
it('should NOT execute ntfy hook when endpoint is disabled', async () => {
|
||||
const disabledEndpoint = {
|
||||
...mockNtfyEndpoint,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Disabled Endpoint',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEventHistoryService.storeEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fetch should not be called because endpoint is disabled
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use hook-specific values over endpoint defaults', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaults = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'default-emoji',
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Overrides',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
tags: 'override-tag',
|
||||
emoji: 'override-emoji',
|
||||
clickUrl: 'https://override.example.com',
|
||||
priority: 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-1',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Hook values should override endpoint defaults
|
||||
expect(options.headers['Tags']).toBe('override-emoji,override-tag');
|
||||
expect(options.headers['Click']).toBe('https://override.example.com');
|
||||
expect(options.headers['Priority']).toBe('5');
|
||||
});
|
||||
|
||||
describe('click URL deep linking', () => {
|
||||
it('should generate board URL with featureId query param when feature context is available', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://app.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'test-feature-123',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should use /board path with featureId query param
|
||||
expect(clickUrl).toContain('/board');
|
||||
expect(clickUrl).toContain('featureId=test-feature-123');
|
||||
// Should NOT use the old path-based format
|
||||
expect(clickUrl).not.toContain('/feature/');
|
||||
});
|
||||
|
||||
it('should generate board URL without featureId when no feature context', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://app.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'auto_mode_complete',
|
||||
name: 'Auto Mode Complete Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
// Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete)
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_idle',
|
||||
executionMode: 'auto',
|
||||
projectPath: '/test/project',
|
||||
totalFeatures: 5,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should navigate to board without featureId
|
||||
expect(clickUrl).toContain('/board');
|
||||
expect(clickUrl).not.toContain('featureId=');
|
||||
});
|
||||
|
||||
it('should use hook-specific click URL overriding default with featureId', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook with Custom Click URL',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
clickUrl: 'https://custom.example.com/custom-page',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-789',
|
||||
featureName: 'Custom URL Test',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should use the hook-specific click URL (not modified with featureId since it's a custom URL)
|
||||
expect(clickUrl).toBe('https://custom.example.com/custom-page');
|
||||
});
|
||||
|
||||
it('should preserve existing query params when adding featureId', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpointWithDefaultClickUrl = {
|
||||
...mockNtfyEndpoint,
|
||||
defaultClickUrl: 'https://app.example.com/board?view=list',
|
||||
};
|
||||
|
||||
const hooks = [
|
||||
{
|
||||
id: 'ntfy-hook-1',
|
||||
enabled: true,
|
||||
trigger: 'feature_success',
|
||||
name: 'Ntfy Hook',
|
||||
action: {
|
||||
type: 'ntfy',
|
||||
endpointId: 'endpoint-1',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]);
|
||||
service.initialize(
|
||||
mockEmitter,
|
||||
mockSettingsService,
|
||||
mockEventHistoryService,
|
||||
mockFeatureLoader
|
||||
);
|
||||
|
||||
mockEmitter.simulateEvent('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
executionMode: 'auto',
|
||||
featureId: 'feat-456',
|
||||
featureName: 'Test Feature',
|
||||
passes: true,
|
||||
message: 'Feature completed',
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
const clickUrl = options.headers['Click'];
|
||||
|
||||
// Should preserve existing query params and add featureId
|
||||
expect(clickUrl).toContain('view=list');
|
||||
expect(clickUrl).toContain('featureId=feat-456');
|
||||
// Should be properly formatted URL
|
||||
expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,6 +279,81 @@ describe('FeatureStateManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature.title as notification title for waiting_approval status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: 'My Awesome Feature Title',
|
||||
name: 'old-name-property', // name property exists but should not be used
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'My Awesome Feature Title',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithoutTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: undefined,
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithoutTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithEmptyTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: '',
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithEmptyTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create notification for verified status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
@@ -298,6 +373,81 @@ describe('FeatureStateManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature.title as notification title for verified status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: 'My Awesome Feature Title',
|
||||
name: 'old-name-property', // name property exists but should not be used
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'My Awesome Feature Title',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithoutTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: undefined,
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithoutTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string title by using featureId as notification title in verified notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithEmptyTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: '',
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithEmptyTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should sync to app_spec for completed status', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature },
|
||||
@@ -1211,4 +1361,179 @@ describe('FeatureStateManager', () => {
|
||||
expect(callOrder).toEqual(['persist', 'emit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAutoModeEventError', () => {
|
||||
let subscribeCallback: (type: string, payload: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0]
|
||||
// subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0]
|
||||
const mockCalls = (mockEvents.subscribe as Mock).mock.calls;
|
||||
if (mockCalls.length > 0 && mockCalls[0].length > 0) {
|
||||
subscribeCallback = mockCalls[0][0] as typeof subscribeCallback;
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore events with no type', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore non-error events', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create auto_mode_error notification with gesture name as title when no featureId', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
title: 'Auto Mode Error',
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/project',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use error field instead of message when available', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Some message',
|
||||
error: 'The actual error',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
message: 'The actual error',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature title as notification title for feature error with featureId', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, title: 'Login Page Feature' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: false,
|
||||
featureId: 'feature-123',
|
||||
error: 'Build failed',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
// Wait for async handleAutoModeEventError to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_error',
|
||||
title: 'Login Page Feature',
|
||||
message: 'Feature Failed: Build failed',
|
||||
featureId: 'feature-123',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore auto_mode_feature_complete without passes=false', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing projectPath gracefully', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Error occurred',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle notification service failures gracefully', async () => {
|
||||
(getNotificationService as Mock).mockImplementation(() => {
|
||||
throw new Error('Service unavailable');
|
||||
});
|
||||
|
||||
// Should not throw - the callback returns void so we just call it and wait for async work
|
||||
subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Error',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
// Give async handleAutoModeEventError time to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should unsubscribe from event subscription', () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
|
||||
|
||||
// Create a new manager to get a fresh subscription
|
||||
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
// Call destroy
|
||||
newManager.destroy();
|
||||
|
||||
// Verify unsubscribe was called
|
||||
expect(unsubscribeFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle destroy being called multiple times', () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
|
||||
|
||||
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
// Call destroy multiple times
|
||||
newManager.destroy();
|
||||
newManager.destroy();
|
||||
|
||||
// Should only unsubscribe once
|
||||
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NtfyService } from '../../../src/services/ntfy-service.js';
|
||||
import type { NtfyEndpointConfig } from '@automaker/types';
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('NtfyService', () => {
|
||||
let service: NtfyService;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new NtfyService();
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a valid endpoint config for testing
|
||||
*/
|
||||
function createEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
|
||||
return {
|
||||
id: 'test-endpoint-id',
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic context for testing
|
||||
*/
|
||||
function createContext() {
|
||||
return {
|
||||
featureId: 'feat-123',
|
||||
featureName: 'Test Feature',
|
||||
projectPath: '/test/project',
|
||||
projectName: 'test-project',
|
||||
timestamp: '2024-01-15T10:30:00.000Z',
|
||||
eventType: 'feature_success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateEndpoint', () => {
|
||||
it('should return null for valid endpoint with no auth', () => {
|
||||
const endpoint = createEndpoint();
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid endpoint with basic auth', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid endpoint with token auth', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: 'tk_123456',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error when serverUrl is missing', () => {
|
||||
const endpoint = createEndpoint({ serverUrl: '' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Server URL is required');
|
||||
});
|
||||
|
||||
it('should return error when serverUrl is invalid', () => {
|
||||
const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Invalid server URL format');
|
||||
});
|
||||
|
||||
it('should return error when topic is missing', () => {
|
||||
const endpoint = createEndpoint({ topic: '' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic is required');
|
||||
});
|
||||
|
||||
it('should return error when topic contains spaces', () => {
|
||||
const endpoint = createEndpoint({ topic: 'invalid topic' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic cannot contain spaces');
|
||||
});
|
||||
|
||||
it('should return error when topic contains tabs', () => {
|
||||
const endpoint = createEndpoint({ topic: 'invalid\ttopic' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic cannot contain spaces');
|
||||
});
|
||||
|
||||
it('should return error when basic auth is missing username', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: '',
|
||||
password: 'pass',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Username and password are required for basic authentication');
|
||||
});
|
||||
|
||||
it('should return error when basic auth is missing password', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: '',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Username and password are required for basic authentication');
|
||||
});
|
||||
|
||||
it('should return error when token auth is missing token', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: '',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Access token is required for token authentication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendNotification', () => {
|
||||
it('should return error when endpoint is disabled', async () => {
|
||||
const endpoint = createEndpoint({ enabled: false });
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Endpoint is disabled');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when endpoint validation fails', async () => {
|
||||
const endpoint = createEndpoint({ serverUrl: '' });
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Server URL is required');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send notification with default values', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8');
|
||||
expect(options.headers['Title']).toContain('Feature Completed');
|
||||
expect(options.headers['Priority']).toBe('3');
|
||||
});
|
||||
|
||||
it('should send notification with custom title and body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: 'Custom Title',
|
||||
body: 'Custom body message',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Custom Title');
|
||||
expect(options.body).toBe('Custom body message');
|
||||
});
|
||||
|
||||
it('should send notification with tags and emoji', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
tags: 'warning,skull',
|
||||
emoji: 'tada',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('tada,warning,skull');
|
||||
});
|
||||
|
||||
it('should send notification with priority', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, { priority: 5 }, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Priority']).toBe('5');
|
||||
});
|
||||
|
||||
it('should send notification with click URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{ clickUrl: 'https://example.com/feature/123' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Click']).toBe('https://example.com/feature/123');
|
||||
});
|
||||
|
||||
it('should use endpoint default tags and emoji when not specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'rocket',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('rocket,default-tag');
|
||||
});
|
||||
|
||||
it('should use endpoint default click URL when not specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Click']).toBe('https://default.example.com');
|
||||
});
|
||||
|
||||
it('should send notification with basic authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Basic auth should be base64 encoded
|
||||
const expectedAuth = Buffer.from('testuser:testpass').toString('base64');
|
||||
expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`);
|
||||
});
|
||||
|
||||
it('should send notification with token authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: 'tk_test_token_123',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123');
|
||||
});
|
||||
|
||||
it('should return error on HTTP error response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve('Forbidden - invalid token'),
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('403');
|
||||
expect(result.error).toContain('Forbidden');
|
||||
});
|
||||
|
||||
it('should return error on timeout', async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Request timed out');
|
||||
});
|
||||
|
||||
it('should return error on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
|
||||
it('should handle server URL with trailing slash', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' });
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const url = mockFetch.mock.calls[0][0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
});
|
||||
|
||||
it('should URL encode the topic', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({ topic: 'test/topic#special' });
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const url = mockFetch.mock.calls[0][0];
|
||||
expect(url).toContain('test%2Ftopic%23special');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable substitution', () => {
|
||||
it('should substitute {{featureId}} in title', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: 'Feature {{featureId}} completed' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature feat-123 completed');
|
||||
});
|
||||
|
||||
it('should substitute {{featureName}} in body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ body: 'The feature "{{featureName}}" is done!' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toBe('The feature "Test Feature" is done!');
|
||||
});
|
||||
|
||||
it('should substitute {{projectName}} in title', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: '[{{projectName}}] Event: {{eventType}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[test-project] Event: feature_success');
|
||||
});
|
||||
|
||||
it('should substitute {{timestamp}} in body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ body: 'Completed at: {{timestamp}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should substitute {{error}} in body for error events', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'feature_error',
|
||||
error: 'Something went wrong',
|
||||
};
|
||||
await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Error: Something went wrong');
|
||||
});
|
||||
|
||||
it('should substitute multiple variables', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: '[{{projectName}}] {{featureName}}',
|
||||
body: 'Feature {{featureId}} completed at {{timestamp}}',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[test-project] Test Feature');
|
||||
expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should replace unknown variables with empty string', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: 'Value: {{unknownVariable}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Value: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default title generation', () => {
|
||||
it('should generate title with feature name for feature_success', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Completed: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate title without feature name when missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), featureName: undefined };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Completed');
|
||||
});
|
||||
|
||||
it('should generate correct title for feature_created', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'feature_created' };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Created: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate correct title for feature_error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'feature_error' };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Failed: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate correct title for auto_mode_complete', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'auto_mode_complete',
|
||||
featureName: undefined,
|
||||
};
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Auto Mode Complete');
|
||||
});
|
||||
|
||||
it('should generate correct title for auto_mode_error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Auto Mode Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default body generation', () => {
|
||||
it('should generate body with feature info', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toContain('Feature: Test Feature');
|
||||
expect(options.body).toContain('ID: feat-123');
|
||||
expect(options.body).toContain('Project: test-project');
|
||||
expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should include error in body for error events', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'feature_error',
|
||||
error: 'Build failed',
|
||||
};
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toContain('Error: Build failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emoji and tags handling', () => {
|
||||
it('should handle emoji shortcode with colons', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('tada');
|
||||
});
|
||||
|
||||
it('should handle emoji without colons', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, { emoji: 'warning' }, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('warning');
|
||||
});
|
||||
|
||||
it('should combine emoji and tags correctly', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ emoji: 'rotating_light', tags: 'urgent,alert' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Emoji comes first, then tags
|
||||
expect(options.headers['Tags']).toBe('rotating_light,urgent,alert');
|
||||
});
|
||||
|
||||
it('should ignore emoji with spaces', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ emoji: 'multi word emoji', tags: 'test' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,28 @@ import {
|
||||
type Credentials,
|
||||
type ProjectSettings,
|
||||
} from '@/types/settings.js';
|
||||
import type { NtfyEndpointConfig } from '@automaker/types';
|
||||
|
||||
describe('settings-service.ts', () => {
|
||||
let testDataDir: string;
|
||||
let testProjectDir: string;
|
||||
let settingsService: SettingsService;
|
||||
|
||||
/**
|
||||
* Helper to create a test ntfy endpoint with sensible defaults
|
||||
*/
|
||||
function createTestNtfyEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
|
||||
return {
|
||||
id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
|
||||
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
|
||||
@@ -171,6 +187,150 @@ describe('settings-service.ts', () => {
|
||||
expect(updated.theme).toBe('solarized');
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => {
|
||||
const endpoint1 = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'My Ntfy',
|
||||
topic: 'my-topic',
|
||||
});
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [endpoint1] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [],
|
||||
} as any);
|
||||
|
||||
// The empty array should be ignored - existing endpoints should be preserved
|
||||
expect(updated.ntfyEndpoints?.length).toBe(1);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
});
|
||||
|
||||
it('should allow adding new ntfyEndpoints to existing list', async () => {
|
||||
const endpoint1 = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'First Endpoint',
|
||||
topic: 'first-topic',
|
||||
});
|
||||
const endpoint2 = createTestNtfyEndpoint({
|
||||
id: 'endpoint-2',
|
||||
name: 'Second Endpoint',
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'second-topic',
|
||||
authType: 'token',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [endpoint1] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [endpoint1, endpoint2] as any,
|
||||
});
|
||||
|
||||
// Both endpoints should be present
|
||||
expect(updated.ntfyEndpoints?.length).toBe(2);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2');
|
||||
});
|
||||
|
||||
it('should allow updating ntfyEndpoints with non-empty array', async () => {
|
||||
const originalEndpoint = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'Original Name',
|
||||
topic: 'original-topic',
|
||||
});
|
||||
const updatedEndpoint = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'Updated Name',
|
||||
topic: 'updated-topic',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [originalEndpoint] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [updatedEndpoint] as any,
|
||||
});
|
||||
|
||||
// The update should go through with the new values
|
||||
expect(updated.ntfyEndpoints?.length).toBe(1);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name');
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic');
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => {
|
||||
// Start with no endpoints (default state)
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2));
|
||||
|
||||
// Trying to set empty array should be fine when there are no existing endpoints
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [],
|
||||
} as any);
|
||||
|
||||
// Empty array should be set (no data loss because there was nothing to lose)
|
||||
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve ntfyEndpoints while updating other settings', async () => {
|
||||
const endpoint = createTestNtfyEndpoint({
|
||||
id: 'endpoint-1',
|
||||
name: 'My Endpoint',
|
||||
topic: 'my-topic',
|
||||
});
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
theme: 'dark',
|
||||
ntfyEndpoints: [endpoint] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
// Update theme without sending ntfyEndpoints
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
theme: 'light',
|
||||
});
|
||||
|
||||
// Theme should be updated
|
||||
expect(updated.theme).toBe('light');
|
||||
// ntfyEndpoints should be preserved from existing settings
|
||||
expect(updated.ntfyEndpoints?.length).toBe(1);
|
||||
expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
});
|
||||
|
||||
it('should allow clearing ntfyEndpoints with escape hatch flag', async () => {
|
||||
const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' });
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
ntfyEndpoints: [endpoint] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
// Use escape hatch to intentionally clear ntfyEndpoints
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
ntfyEndpoints: [],
|
||||
__allowEmptyNtfyEndpoints: true,
|
||||
} as any);
|
||||
|
||||
// The empty array should be applied because escape hatch was used
|
||||
expect(updated.ntfyEndpoints?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('should create data directory if it does not exist', async () => {
|
||||
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
||||
const newService = new SettingsService(newDataDir);
|
||||
@@ -562,6 +722,73 @@ describe('settings-service.ts', () => {
|
||||
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
|
||||
});
|
||||
|
||||
it('should migrate ntfyEndpoints from localStorage data', async () => {
|
||||
const localStorageData = {
|
||||
'automaker-storage': JSON.stringify({
|
||||
state: {
|
||||
ntfyEndpoints: [
|
||||
{
|
||||
id: 'endpoint-1',
|
||||
name: 'My Ntfy Server',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'my-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.migratedGlobalSettings).toBe(true);
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.ntfyEndpoints?.length).toBe(1);
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server');
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic');
|
||||
});
|
||||
|
||||
it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => {
|
||||
const localStorageData = {
|
||||
'automaker-storage': JSON.stringify({
|
||||
state: {
|
||||
eventHooks: [
|
||||
{
|
||||
id: 'hook-1',
|
||||
name: 'Test Hook',
|
||||
eventType: 'feature:started',
|
||||
enabled: true,
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
ntfyEndpoints: [
|
||||
{
|
||||
id: 'endpoint-1',
|
||||
name: 'My Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.eventHooks?.length).toBe(1);
|
||||
expect(settings.ntfyEndpoints?.length).toBe(1);
|
||||
expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1');
|
||||
expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1');
|
||||
});
|
||||
|
||||
it('should handle direct localStorage values', async () => {
|
||||
const localStorageData = {
|
||||
'automaker:lastProjectDir': '/path/to/project',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.15.0",
|
||||
"version": "1.0.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"desktopName": "automaker.desktop",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.0.0 <23.0.0"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Bell, Check, Trash2 } from 'lucide-react';
|
||||
import { Bell, Check, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
||||
@@ -11,25 +11,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import type { Notification } from '@automaker/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
interface NotificationBellProps {
|
||||
projectPath: string | null;
|
||||
@@ -86,7 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
navigate({ to: '/board' });
|
||||
navigate({ to: '/board', search: { featureId: notification.featureId } });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, setPopoverOpen, navigate]
|
||||
@@ -105,6 +87,10 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-4 w-4 text-blue-500" />;
|
||||
case 'feature_error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'auto_mode_error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
@@ -195,8 +195,10 @@ export function SessionManager({
|
||||
if (result.success && result.session?.id) {
|
||||
setNewSessionName('');
|
||||
setIsCreating(false);
|
||||
await invalidateSessions();
|
||||
// Select the new session immediately before invalidating the cache to avoid
|
||||
// a race condition where the cache re-render resets the selected session.
|
||||
onSelectSession(result.session.id);
|
||||
await invalidateSessions();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,8 +212,10 @@ export function SessionManager({
|
||||
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
await invalidateSessions();
|
||||
// Select the new session immediately before invalidating the cache to avoid
|
||||
// a race condition where the cache re-render resets the selected session.
|
||||
onSelectSession(result.session.id);
|
||||
await invalidateSessions();
|
||||
}
|
||||
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);
|
||||
|
||||
|
||||
@@ -114,7 +114,12 @@ const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWo
|
||||
|
||||
const logger = createLogger('Board');
|
||||
|
||||
export function BoardView() {
|
||||
interface BoardViewProps {
|
||||
/** Feature ID from URL parameter - if provided, opens output modal for this feature on load */
|
||||
initialFeatureId?: string;
|
||||
}
|
||||
|
||||
export function BoardView({ initialFeatureId }: BoardViewProps) {
|
||||
const {
|
||||
currentProject,
|
||||
defaultSkipTests,
|
||||
@@ -300,6 +305,93 @@ export function BoardView() {
|
||||
setFeaturesWithContext,
|
||||
});
|
||||
|
||||
// Handle initial feature ID from URL - switch to the correct worktree and open output modal
|
||||
// Uses a ref to track which featureId has been handled to prevent re-opening
|
||||
// when the component re-renders but initialFeatureId hasn't changed.
|
||||
// We read worktrees from the store reactively so this effect re-runs once worktrees load.
|
||||
const handledFeatureIdRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Reset the handled ref whenever initialFeatureId changes (including to undefined),
|
||||
// so navigating to the same featureId again after clearing works correctly.
|
||||
useEffect(() => {
|
||||
handledFeatureIdRef.current = undefined;
|
||||
}, [initialFeatureId]);
|
||||
const deepLinkWorktrees = useAppStore(
|
||||
useCallback(
|
||||
(s) =>
|
||||
currentProject?.path
|
||||
? (s.worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProject?.path]
|
||||
)
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!initialFeatureId ||
|
||||
handledFeatureIdRef.current === initialFeatureId ||
|
||||
isLoading ||
|
||||
!hookFeatures.length ||
|
||||
!currentProject?.path
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = hookFeatures.find((f) => f.id === initialFeatureId);
|
||||
if (!feature) return;
|
||||
|
||||
// If the feature has a branch, wait for worktrees to load so we can switch
|
||||
if (feature.branchName && deepLinkWorktrees.length === 0) {
|
||||
return; // Worktrees not loaded yet - effect will re-run when they load
|
||||
}
|
||||
|
||||
// Switch to the correct worktree based on the feature's branchName
|
||||
if (feature.branchName && deepLinkWorktrees.length > 0) {
|
||||
const targetWorktree = deepLinkWorktrees.find((w) => w.branch === feature.branchName);
|
||||
if (targetWorktree) {
|
||||
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
|
||||
const isAlreadySelected = targetWorktree.isMain
|
||||
? currentWt?.path === null
|
||||
: currentWt?.path === targetWorktree.path;
|
||||
if (!isAlreadySelected) {
|
||||
logger.info(
|
||||
`Deep link: switching to worktree "${targetWorktree.branch}" for feature ${initialFeatureId}`
|
||||
);
|
||||
setCurrentWorktree(
|
||||
currentProject.path,
|
||||
targetWorktree.isMain ? null : targetWorktree.path,
|
||||
targetWorktree.branch
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (!feature.branchName && deepLinkWorktrees.length > 0) {
|
||||
// Feature has no branch - should be on the main worktree
|
||||
const currentWt = useAppStore.getState().getCurrentWorktree(currentProject.path);
|
||||
if (currentWt?.path !== null && currentWt !== null) {
|
||||
const mainWorktree = deepLinkWorktrees.find((w) => w.isMain);
|
||||
if (mainWorktree) {
|
||||
logger.info(
|
||||
`Deep link: switching to main worktree for unassigned feature ${initialFeatureId}`
|
||||
);
|
||||
setCurrentWorktree(currentProject.path, null, mainWorktree.branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Opening output modal for feature from URL: ${initialFeatureId}`);
|
||||
setOutputFeature(feature);
|
||||
setShowOutputModal(true);
|
||||
handledFeatureIdRef.current = initialFeatureId;
|
||||
}, [
|
||||
initialFeatureId,
|
||||
isLoading,
|
||||
hookFeatures,
|
||||
currentProject?.path,
|
||||
deepLinkWorktrees,
|
||||
setCurrentWorktree,
|
||||
setOutputFeature,
|
||||
setShowOutputModal,
|
||||
]);
|
||||
|
||||
// Load pipeline config when project changes
|
||||
useEffect(() => {
|
||||
if (!currentProject?.path) return;
|
||||
@@ -1988,7 +2080,10 @@ export function BoardView() {
|
||||
{/* Agent Output Modal */}
|
||||
<AgentOutputModal
|
||||
open={showOutputModal}
|
||||
onClose={() => setShowOutputModal(false)}
|
||||
onClose={() => {
|
||||
setShowOutputModal(false);
|
||||
handledFeatureIdRef.current = undefined;
|
||||
}}
|
||||
featureDescription={outputFeature?.description || ''}
|
||||
featureId={outputFeature?.id || ''}
|
||||
featureStatus={outputFeature?.status}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||
>(new Map());
|
||||
// Track real-time task summary updates from WebSocket events
|
||||
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string>>(new Map());
|
||||
const [taskSummaryMap, setTaskSummaryMap] = useState<Map<string, string | null>>(new Map());
|
||||
// Track last WebSocket event timestamp to know if we're receiving real-time updates
|
||||
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
|
||||
|
||||
@@ -200,7 +200,11 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
// Derive effective todos from planSpec.tasks when available, fallback to agentInfo.todos
|
||||
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
|
||||
const effectiveTodos = useMemo(() => {
|
||||
const effectiveTodos = useMemo((): {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
summary?: string | null;
|
||||
}[] => {
|
||||
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||
|
||||
@@ -250,7 +254,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
||||
return {
|
||||
content: task.description,
|
||||
status: effectiveStatus,
|
||||
summary: realtimeSummary ?? task.summary,
|
||||
summary: taskSummaryMap.has(task.id) ? realtimeSummary : task.summary,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,6 +240,12 @@ export const ListView = memo(function ListView({
|
||||
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
||||
|
||||
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
||||
const effectiveSortConfig: SortConfig = useMemo(
|
||||
() => (sortNewestCardOnTop ? { column: 'createdAt', direction: 'desc' } : sortConfig),
|
||||
[sortNewestCardOnTop, sortConfig]
|
||||
);
|
||||
|
||||
// Generate status groups from columnFeaturesMap
|
||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
||||
@@ -454,7 +460,7 @@ export const ListView = memo(function ListView({
|
||||
>
|
||||
{/* Table header */}
|
||||
<ListHeader
|
||||
sortConfig={sortConfig}
|
||||
sortConfig={effectiveSortConfig}
|
||||
onSortChange={onSortChange}
|
||||
showCheckbox={isSelectionMode}
|
||||
allSelected={selectionState.allSelected}
|
||||
|
||||
@@ -20,7 +20,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
@@ -93,6 +100,7 @@ export function CreatePRDialog({
|
||||
|
||||
// Generate description state
|
||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
|
||||
// PR description model override
|
||||
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
|
||||
@@ -286,6 +294,7 @@ export function CreatePRDialog({
|
||||
setSelectedRemote('');
|
||||
setSelectedTargetRemote('');
|
||||
setIsGeneratingDescription(false);
|
||||
setIsDescriptionExpanded(false);
|
||||
operationCompletedRef.current = false;
|
||||
}, [defaultBaseBranch]);
|
||||
|
||||
@@ -642,13 +651,28 @@ export function CreatePRDialog({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||
className="h-6 px-2 text-xs"
|
||||
title={isDescriptionExpanded ? 'Collapse description' : 'Expand description'}
|
||||
>
|
||||
{isDescriptionExpanded ? (
|
||||
<Minimize2 className="w-3 h-3" />
|
||||
) : (
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
placeholder="Describe the changes in this PR..."
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
className={isDescriptionExpanded ? 'min-h-[300px]' : 'min-h-[80px]'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
/**
|
||||
* Qualify a branch name with a remote prefix when appropriate.
|
||||
* Returns undefined when branch is empty, and avoids double-prefixing.
|
||||
*/
|
||||
function qualifyRemoteBranch(remote: string, branch?: string): string | undefined {
|
||||
const trimmed = branch?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (remote === 'local') return trimmed;
|
||||
if (trimmed.startsWith(`${remote}/`)) return trimmed;
|
||||
return `${remote}/${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git/worktree error messages and return user-friendly versions
|
||||
*/
|
||||
@@ -264,19 +276,21 @@ export function CreateWorktreeDialog({
|
||||
return availableBranches.filter((b) => !b.isRemote).map((b) => b.name);
|
||||
}
|
||||
|
||||
// If a specific remote is selected, show only branches from that remote
|
||||
// If a specific remote is selected, show only branches from that remote (without remote prefix)
|
||||
const remoteBranchList = remoteBranches.get(selectedRemote);
|
||||
if (remoteBranchList) {
|
||||
return remoteBranchList.map((b) => b.fullRef);
|
||||
return remoteBranchList.map((b) => b.name);
|
||||
}
|
||||
|
||||
// Fallback: filter from available branches by remote prefix
|
||||
// Fallback: filter from available branches by remote prefix, stripping the prefix for display
|
||||
const prefix = `${selectedRemote}/`;
|
||||
return availableBranches
|
||||
.filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`))
|
||||
.map((b) => b.name);
|
||||
.filter((b) => b.isRemote && b.name.startsWith(prefix))
|
||||
.map((b) => b.name.substring(prefix.length));
|
||||
}, [availableBranches, selectedRemote, remoteBranches]);
|
||||
|
||||
// Determine if the selected base branch is a remote branch.
|
||||
// When a remote is selected in the source dropdown, the branch is always remote.
|
||||
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
||||
// the branch isn't in the fetched availableBranches list.
|
||||
@@ -285,6 +299,8 @@ export function CreateWorktreeDialog({
|
||||
// If the branch list couldn't be fetched, availableBranches is a fallback
|
||||
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
|
||||
if (branchFetchError) return false;
|
||||
// If a remote is explicitly selected, the branch is remote
|
||||
if (selectedRemote !== 'local') return true;
|
||||
// Check fetched branch list first
|
||||
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
||||
if (knownRemote) return true;
|
||||
@@ -295,7 +311,7 @@ export function CreateWorktreeDialog({
|
||||
return !isKnownLocal;
|
||||
}
|
||||
return false;
|
||||
}, [baseBranch, availableBranches, branchFetchError]);
|
||||
}, [baseBranch, availableBranches, branchFetchError, selectedRemote]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!branchName.trim()) {
|
||||
@@ -334,8 +350,10 @@ export function CreateWorktreeDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
|
||||
const effectiveBaseBranch = trimmedBaseBranch || undefined;
|
||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD).
|
||||
// When a remote is selected, prepend the remote name to form the full ref
|
||||
// (e.g. "main" with remote "origin" becomes "origin/main").
|
||||
const effectiveBaseBranch = qualifyRemoteBranch(selectedRemote, trimmedBaseBranch);
|
||||
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
|
||||
|
||||
if (result.success && result.worktree) {
|
||||
@@ -435,7 +453,7 @@ export function CreateWorktreeDialog({
|
||||
<span>Base Branch</span>
|
||||
{baseBranch && !showBaseBranch && (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
||||
{baseBranch}
|
||||
{qualifyRemoteBranch(selectedRemote, baseBranch) ?? baseBranch}
|
||||
</code>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function WorktreeDropdownItem({
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-amber-500"
|
||||
title="Dev server starting..."
|
||||
>
|
||||
<Spinner size="xs" variant="current" />
|
||||
<Spinner size="xs" variant="primary" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ export function WorktreeDropdown({
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-amber-500 shrink-0"
|
||||
title="Dev server starting..."
|
||||
>
|
||||
<Spinner size="xs" variant="current" />
|
||||
<Spinner size="xs" variant="primary" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -561,7 +561,7 @@ export function WorktreeDropdown({
|
||||
}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingAnyDevServer}
|
||||
isStartingAnyDevServer={isStartingAnyDevServer}
|
||||
isDevServerStarting={isDevServerStarting(selectedWorktree)}
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
|
||||
@@ -533,7 +533,7 @@ export function WorktreeTab({
|
||||
trackingRemote={trackingRemote}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingAnyDevServer}
|
||||
isStartingAnyDevServer={isStartingAnyDevServer}
|
||||
isDevServerStarting={isDevServerStarting}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
|
||||
@@ -9,28 +9,11 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Bell, Check, CheckCheck, Trash2, ExternalLink, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Notification } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export function NotificationsView() {
|
||||
const { currentProject } = useAppStore();
|
||||
@@ -111,8 +94,8 @@ export function NotificationsView() {
|
||||
|
||||
// Navigate to the relevant view based on notification type
|
||||
if (notification.featureId) {
|
||||
// Navigate to board view - feature will be selected
|
||||
navigate({ to: '/board' });
|
||||
// Navigate to board view with feature ID to show output
|
||||
navigate({ to: '/board', search: { featureId: notification.featureId } });
|
||||
}
|
||||
},
|
||||
[handleMarkAsRead, navigate]
|
||||
@@ -128,6 +111,10 @@ export function NotificationsView() {
|
||||
return <Check className="h-5 w-5 text-blue-500" />;
|
||||
case 'agent_complete':
|
||||
return <Check className="h-5 w-5 text-purple-500" />;
|
||||
case 'feature_error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'auto_mode_error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5" />;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
ideationModel: 'Ideation',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
prDescriptionModel: 'PR Description',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
|
||||
import { Bot, Folder, RefreshCw, Square, Activity, FileText, Cpu } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -16,6 +16,16 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||
import { useRunningAgents } from '@/hooks/queries';
|
||||
import { useStopFeature } from '@/hooks/mutations';
|
||||
import { getModelDisplayName } from '@/lib/utils';
|
||||
|
||||
function formatFeatureId(featureId: string): string {
|
||||
// Strip 'feature-' prefix and timestamp for readability
|
||||
// e.g. 'feature-1772305345138-epit9shpdxl' → 'epit9shpdxl'
|
||||
const match = featureId.match(/^feature-\d+-(.+)$/);
|
||||
if (match) return match[1];
|
||||
// For other patterns like 'backlog-plan:...' or 'spec-generation:...', show as-is
|
||||
return featureId;
|
||||
}
|
||||
|
||||
export function RunningAgentsView() {
|
||||
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||
@@ -156,15 +166,21 @@ export function RunningAgentsView() {
|
||||
|
||||
{/* Agent info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium truncate" title={agent.title || agent.featureId}>
|
||||
{agent.title || agent.featureId}
|
||||
{agent.title || formatFeatureId(agent.featureId)}
|
||||
</span>
|
||||
{agent.isAutoMode && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
{agent.model && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/30 flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{getModelDisplayName(agent.model)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p
|
||||
|
||||
@@ -19,16 +19,19 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Terminal, Globe } from 'lucide-react';
|
||||
import { Terminal, Globe, Bell } from 'lucide-react';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
EventHookHttpMethod,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
} from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EventHookDialogProps {
|
||||
open: boolean;
|
||||
@@ -37,7 +40,7 @@ interface EventHookDialogProps {
|
||||
onSave: (hook: EventHook) => void;
|
||||
}
|
||||
|
||||
type ActionType = 'shell' | 'http';
|
||||
type ActionType = 'shell' | 'http' | 'ntfy';
|
||||
|
||||
const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||
'feature_created',
|
||||
@@ -49,7 +52,17 @@ const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||
|
||||
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 1, label: 'Min (no sound/vibration)' },
|
||||
{ value: 2, label: 'Low' },
|
||||
{ value: 3, label: 'Default' },
|
||||
{ value: 4, label: 'High' },
|
||||
{ value: 5, label: 'Urgent (max)' },
|
||||
];
|
||||
|
||||
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
|
||||
const ntfyEndpoints = useAppStore((state) => state.ntfyEndpoints);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
|
||||
@@ -65,6 +78,15 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
const [headers, setHeaders] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
// Ntfy action state
|
||||
const [ntfyEndpointId, setNtfyEndpointId] = useState('');
|
||||
const [ntfyTitle, setNtfyTitle] = useState('');
|
||||
const [ntfyBody, setNtfyBody] = useState('');
|
||||
const [ntfyTags, setNtfyTags] = useState('');
|
||||
const [ntfyEmoji, setNtfyEmoji] = useState('');
|
||||
const [ntfyClickUrl, setNtfyClickUrl] = useState('');
|
||||
const [ntfyPriority, setNtfyPriority] = useState<1 | 2 | 3 | 4 | 5>(3);
|
||||
|
||||
// Reset form when dialog opens/closes or editingHook changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -72,68 +94,131 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
// Populate form with existing hook data
|
||||
setName(editingHook.name || '');
|
||||
setTrigger(editingHook.trigger);
|
||||
setActionType(editingHook.action.type);
|
||||
setActionType(editingHook.action.type as ActionType);
|
||||
|
||||
if (editingHook.action.type === 'shell') {
|
||||
const shellAction = editingHook.action as EventHookShellAction;
|
||||
setCommand(shellAction.command);
|
||||
setTimeout(String(shellAction.timeout || 30000));
|
||||
// Reset HTTP fields
|
||||
setUrl('');
|
||||
setMethod('POST');
|
||||
setHeaders('');
|
||||
setBody('');
|
||||
} else {
|
||||
// Reset other fields
|
||||
resetHttpFields();
|
||||
resetNtfyFields();
|
||||
} else if (editingHook.action.type === 'http') {
|
||||
const httpAction = editingHook.action as EventHookHttpAction;
|
||||
setUrl(httpAction.url);
|
||||
setMethod(httpAction.method);
|
||||
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
|
||||
setBody(httpAction.body || '');
|
||||
// Reset shell fields
|
||||
setCommand('');
|
||||
setTimeout('30000');
|
||||
// Reset other fields
|
||||
resetShellFields();
|
||||
resetNtfyFields();
|
||||
} else if (editingHook.action.type === 'ntfy') {
|
||||
const ntfyAction = editingHook.action as EventHookNtfyAction;
|
||||
setNtfyEndpointId(ntfyAction.endpointId);
|
||||
setNtfyTitle(ntfyAction.title || '');
|
||||
setNtfyBody(ntfyAction.body || '');
|
||||
setNtfyTags(ntfyAction.tags || '');
|
||||
setNtfyEmoji(ntfyAction.emoji || '');
|
||||
setNtfyClickUrl(ntfyAction.clickUrl || '');
|
||||
setNtfyPriority(ntfyAction.priority || 3);
|
||||
// Reset other fields
|
||||
resetShellFields();
|
||||
resetHttpFields();
|
||||
}
|
||||
} else {
|
||||
// Reset to defaults for new hook
|
||||
setName('');
|
||||
setTrigger('feature_success');
|
||||
setActionType('shell');
|
||||
setCommand('');
|
||||
setTimeout('30000');
|
||||
setUrl('');
|
||||
setMethod('POST');
|
||||
setHeaders('');
|
||||
setBody('');
|
||||
resetShellFields();
|
||||
resetHttpFields();
|
||||
resetNtfyFields();
|
||||
}
|
||||
}
|
||||
}, [open, editingHook]);
|
||||
|
||||
const resetShellFields = () => {
|
||||
setCommand('');
|
||||
setTimeout('30000');
|
||||
};
|
||||
|
||||
const resetHttpFields = () => {
|
||||
setUrl('');
|
||||
setMethod('POST');
|
||||
setHeaders('');
|
||||
setBody('');
|
||||
};
|
||||
|
||||
const resetNtfyFields = () => {
|
||||
setNtfyEndpointId('');
|
||||
setNtfyTitle('');
|
||||
setNtfyBody('');
|
||||
setNtfyTags('');
|
||||
setNtfyEmoji('');
|
||||
setNtfyClickUrl('');
|
||||
setNtfyPriority(3);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
let action: EventHook['action'];
|
||||
|
||||
if (actionType === 'shell') {
|
||||
action = {
|
||||
type: 'shell',
|
||||
command,
|
||||
timeout: parseInt(timeout, 10) || 30000,
|
||||
};
|
||||
} else if (actionType === 'http') {
|
||||
// Parse headers JSON with error handling
|
||||
let parsedHeaders: Record<string, string> | undefined;
|
||||
if (headers.trim()) {
|
||||
try {
|
||||
parsedHeaders = JSON.parse(headers);
|
||||
} catch {
|
||||
// If JSON is invalid, show error and don't save
|
||||
toast.error('Invalid JSON in Headers field');
|
||||
return;
|
||||
}
|
||||
}
|
||||
action = {
|
||||
type: 'http',
|
||||
url,
|
||||
method,
|
||||
headers: parsedHeaders,
|
||||
body: body.trim() || undefined,
|
||||
};
|
||||
} else {
|
||||
action = {
|
||||
type: 'ntfy',
|
||||
endpointId: ntfyEndpointId,
|
||||
title: ntfyTitle.trim() || undefined,
|
||||
body: ntfyBody.trim() || undefined,
|
||||
tags: ntfyTags.trim() || undefined,
|
||||
emoji: ntfyEmoji.trim() || undefined,
|
||||
clickUrl: ntfyClickUrl.trim() || undefined,
|
||||
priority: ntfyPriority,
|
||||
};
|
||||
}
|
||||
|
||||
const hook: EventHook = {
|
||||
id: editingHook?.id || generateUUID(),
|
||||
name: name.trim() || undefined,
|
||||
trigger,
|
||||
enabled: editingHook?.enabled ?? true,
|
||||
action:
|
||||
actionType === 'shell'
|
||||
? {
|
||||
type: 'shell',
|
||||
command,
|
||||
timeout: parseInt(timeout, 10) || 30000,
|
||||
}
|
||||
: {
|
||||
type: 'http',
|
||||
url,
|
||||
method,
|
||||
headers: headers.trim() ? JSON.parse(headers) : undefined,
|
||||
body: body.trim() || undefined,
|
||||
},
|
||||
action,
|
||||
};
|
||||
|
||||
onSave(hook);
|
||||
};
|
||||
|
||||
const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0;
|
||||
const selectedEndpoint = ntfyEndpoints.find((e) => e.id === ntfyEndpointId);
|
||||
|
||||
const isValid = (() => {
|
||||
if (actionType === 'shell') return command.trim().length > 0;
|
||||
if (actionType === 'http') return url.trim().length > 0;
|
||||
if (actionType === 'ntfy') return Boolean(selectedEndpoint);
|
||||
return false;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -179,13 +264,17 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
<Label>Action Type</Label>
|
||||
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="shell" className="flex-1 gap-2">
|
||||
<TabsTrigger value="shell" className="flex-1 gap-1">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Shell Command
|
||||
<span className="sr-only sm:inline">Shell</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="http" className="flex-1 gap-2">
|
||||
<TabsTrigger value="http" className="flex-1 gap-1">
|
||||
<Globe className="w-4 h-4" />
|
||||
HTTP Request
|
||||
<span className="sr-only sm:inline">HTTP</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ntfy" className="flex-1 gap-1">
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="sr-only sm:inline">Ntfy</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -273,6 +362,139 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Ntfy notification form */}
|
||||
<TabsContent value="ntfy" className="space-y-4 mt-4">
|
||||
{ntfyEndpoints.length === 0 ? (
|
||||
<div className="rounded-lg bg-muted/50 p-4 text-center">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 text-muted-foreground opacity-50" />
|
||||
<p className="text-sm text-muted-foreground">No ntfy endpoints configured.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add an endpoint in the "Endpoints" tab first.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-endpoint">Endpoint *</Label>
|
||||
<Select value={ntfyEndpointId} onValueChange={setNtfyEndpointId}>
|
||||
<SelectTrigger id="ntfy-endpoint">
|
||||
<SelectValue placeholder="Select an endpoint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ntfyEndpoints
|
||||
.filter((e) => e.enabled)
|
||||
.map((endpoint) => (
|
||||
<SelectItem key={endpoint.id} value={endpoint.id}>
|
||||
{endpoint.name} ({endpoint.topic})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedEndpoint && (
|
||||
<div className="rounded-lg bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<strong>Server:</strong> {selectedEndpoint.serverUrl}
|
||||
</p>
|
||||
{selectedEndpoint.defaultTags && (
|
||||
<p>
|
||||
<strong>Default Tags:</strong> {selectedEndpoint.defaultTags}
|
||||
</p>
|
||||
)}
|
||||
{selectedEndpoint.defaultEmoji && (
|
||||
<p>
|
||||
<strong>Default Emoji:</strong> {selectedEndpoint.defaultEmoji}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="ntfy-title"
|
||||
value={ntfyTitle}
|
||||
onChange={(e) => setNtfyTitle(e.target.value)}
|
||||
placeholder="Feature {{featureName}} completed"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to event name. Use {'{{variable}}'} for dynamic values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-body">Message (optional)</Label>
|
||||
<Textarea
|
||||
id="ntfy-body"
|
||||
value={ntfyBody}
|
||||
onChange={(e) => setNtfyBody(e.target.value)}
|
||||
placeholder="Feature {{featureId}} completed at {{timestamp}}"
|
||||
className="font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to event details. Leave empty for auto-generated message.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-tags">Tags (optional)</Label>
|
||||
<Input
|
||||
id="ntfy-tags"
|
||||
value={ntfyTags}
|
||||
onChange={(e) => setNtfyTags(e.target.value)}
|
||||
placeholder="warning,skull"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-emoji">Emoji</Label>
|
||||
<Input
|
||||
id="ntfy-emoji"
|
||||
value={ntfyEmoji}
|
||||
onChange={(e) => setNtfyEmoji(e.target.value)}
|
||||
placeholder="tada"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-click">Click URL (optional)</Label>
|
||||
<Input
|
||||
id="ntfy-click"
|
||||
value={ntfyClickUrl}
|
||||
onChange={(e) => setNtfyClickUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL to open when notification is clicked. Defaults to endpoint setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy-priority">Priority</Label>
|
||||
<Select
|
||||
value={String(ntfyPriority)}
|
||||
onValueChange={(v) => setNtfyPriority(Number(v) as 1 | 2 | 3 | 4 | 5)}
|
||||
>
|
||||
<SelectTrigger id="ntfy-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react';
|
||||
import {
|
||||
Webhook,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Terminal,
|
||||
Globe,
|
||||
History,
|
||||
Bell,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { EventHook, EventHookTrigger } from '@automaker/types';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
NtfyEndpointConfig,
|
||||
NtfyAuthenticationType,
|
||||
} from '@automaker/types';
|
||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||
import { EventHookDialog } from './event-hook-dialog';
|
||||
import { EventHistoryView } from './event-history-view';
|
||||
import { toast } from 'sonner';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('EventHooks');
|
||||
|
||||
type TabType = 'hooks' | 'endpoints' | 'history';
|
||||
|
||||
export function EventHooksSection() {
|
||||
const { eventHooks, setEventHooks } = useAppStore();
|
||||
const { eventHooks, setEventHooks, ntfyEndpoints, setNtfyEndpoints } = useAppStore();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
|
||||
const [activeTab, setActiveTab] = useState<TabType>('hooks');
|
||||
|
||||
// Ntfy endpoint dialog state
|
||||
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<NtfyEndpointConfig | null>(null);
|
||||
|
||||
const handleAddHook = () => {
|
||||
setEditingHook(null);
|
||||
@@ -65,6 +104,57 @@ export function EventHooksSection() {
|
||||
}
|
||||
};
|
||||
|
||||
// Ntfy endpoint handlers
|
||||
const handleAddEndpoint = () => {
|
||||
setEditingEndpoint(null);
|
||||
setEndpointDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditEndpoint = (endpoint: NtfyEndpointConfig) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setEndpointDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = async (endpointId: string) => {
|
||||
try {
|
||||
await setNtfyEndpoints(ntfyEndpoints.filter((e) => e.id !== endpointId));
|
||||
toast.success('Endpoint deleted');
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete ntfy endpoint:', error);
|
||||
toast.error('Failed to delete endpoint');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEndpoint = async (endpointId: string, enabled: boolean) => {
|
||||
try {
|
||||
await setNtfyEndpoints(
|
||||
ntfyEndpoints.map((e) => (e.id === endpointId ? { ...e, enabled } : e))
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle ntfy endpoint:', error);
|
||||
toast.error('Failed to update endpoint');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEndpoint = async (endpoint: NtfyEndpointConfig) => {
|
||||
try {
|
||||
if (editingEndpoint) {
|
||||
// Update existing
|
||||
await setNtfyEndpoints(ntfyEndpoints.map((e) => (e.id === endpoint.id ? endpoint : e)));
|
||||
toast.success('Endpoint updated');
|
||||
} else {
|
||||
// Add new
|
||||
await setNtfyEndpoints([...ntfyEndpoints, endpoint]);
|
||||
toast.success('Endpoint added');
|
||||
}
|
||||
setEndpointDialogOpen(false);
|
||||
setEditingEndpoint(null);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save ntfy endpoint:', error);
|
||||
toast.error('Failed to save endpoint');
|
||||
}
|
||||
};
|
||||
|
||||
// Group hooks by trigger type for better organization
|
||||
const hooksByTrigger = eventHooks.reduce(
|
||||
(acc, hook) => {
|
||||
@@ -96,7 +186,7 @@ export function EventHooksSection() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Run custom commands or webhooks when events occur
|
||||
Run custom commands or send notifications when events occur
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,17 +196,27 @@ export function EventHooksSection() {
|
||||
Add Hook
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'endpoints' && (
|
||||
<Button onClick={handleAddEndpoint} size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Endpoint
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabType)}>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full max-w-xs grid-cols-2">
|
||||
<TabsList className="grid w-full max-w-sm grid-cols-3">
|
||||
<TabsTrigger value="hooks" className="gap-2">
|
||||
<Webhook className="w-4 h-4" />
|
||||
Hooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="endpoints" className="gap-2">
|
||||
<Bell className="w-4 h-4" />
|
||||
Endpoints
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
History
|
||||
@@ -148,6 +248,7 @@ export function EventHooksSection() {
|
||||
<HookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
ntfyEndpoints={ntfyEndpoints}
|
||||
onEdit={() => handleEditHook(hook)}
|
||||
onDelete={() => handleDeleteHook(hook.id)}
|
||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||
@@ -166,12 +267,56 @@ export function EventHooksSection() {
|
||||
<p className="font-medium mb-2">Available variables:</p>
|
||||
<code className="text-[10px] leading-relaxed">
|
||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
{'{{error}}'} {'{{errorType}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Endpoints Tab */}
|
||||
<TabsContent value="endpoints" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
{ntfyEndpoints.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No ntfy endpoints configured</p>
|
||||
<p className="text-xs mt-1">Add endpoints to send push notifications via ntfy.sh</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ntfyEndpoints.map((endpoint) => (
|
||||
<EndpointCard
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
onEdit={() => handleEditEndpoint(endpoint)}
|
||||
onDelete={() => handleDeleteEndpoint(endpoint.id)}
|
||||
onToggle={(enabled) => handleToggleEndpoint(endpoint.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-2">About ntfy.sh:</p>
|
||||
<p className="mb-2">
|
||||
ntfy.sh is a simple pub-sub notification service. Create a topic and subscribe via
|
||||
web, mobile app, or API.
|
||||
</p>
|
||||
<a
|
||||
href="https://ntfy.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
https://ntfy.sh
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
@@ -180,26 +325,51 @@ export function EventHooksSection() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialog */}
|
||||
{/* Hook Dialog */}
|
||||
<EventHookDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingHook={editingHook}
|
||||
onSave={handleSaveHook}
|
||||
/>
|
||||
|
||||
{/* Endpoint Dialog */}
|
||||
<NtfyEndpointDialog
|
||||
open={endpointDialogOpen}
|
||||
onOpenChange={setEndpointDialogOpen}
|
||||
editingEndpoint={editingEndpoint}
|
||||
onSave={handleSaveEndpoint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HookCardProps {
|
||||
hook: EventHook;
|
||||
ntfyEndpoints: NtfyEndpointConfig[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
function HookCard({ hook, ntfyEndpoints, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
const isShell = hook.action.type === 'shell';
|
||||
const isHttp = hook.action.type === 'http';
|
||||
const isNtfy = hook.action.type === 'ntfy';
|
||||
|
||||
// Get ntfy endpoint name if this is an ntfy hook
|
||||
const ntfyEndpointName = isNtfy
|
||||
? ntfyEndpoints.find(
|
||||
(e) => e.id === (hook.action as { type: 'ntfy'; endpointId: string }).endpointId
|
||||
)?.name || 'Unknown endpoint'
|
||||
: null;
|
||||
|
||||
// Get icon background and color
|
||||
const iconStyle = isShell
|
||||
? 'bg-amber-500/10 text-amber-500'
|
||||
: isHttp
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-purple-500/10 text-purple-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -210,24 +380,27 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
)}
|
||||
>
|
||||
{/* Type icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
|
||||
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', iconStyle)}>
|
||||
{isShell ? (
|
||||
<Terminal className="w-4 h-4" />
|
||||
) : isHttp ? (
|
||||
<Globe className="w-4 h-4" />
|
||||
) : (
|
||||
<Bell className="w-4 h-4" />
|
||||
)}
|
||||
>
|
||||
{isShell ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{hook.name || (isShell ? 'Shell Command' : 'HTTP Webhook')}
|
||||
{hook.name || (isShell ? 'Shell Command' : isHttp ? 'HTTP Webhook' : 'Ntfy Notification')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{isShell
|
||||
? (hook.action as { type: 'shell'; command: string }).command
|
||||
: (hook.action as { type: 'http'; url: string }).url}
|
||||
: isHttp
|
||||
? (hook.action as { type: 'http'; url: string }).url
|
||||
: ntfyEndpointName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -249,3 +422,341 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EndpointCardProps {
|
||||
endpoint: NtfyEndpointConfig;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function EndpointCard({ endpoint, onEdit, onDelete, onToggle }: EndpointCardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="endpoint-card"
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-lg border',
|
||||
'bg-background/50 hover:bg-background/80 transition-colors',
|
||||
!endpoint.enabled && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-purple-500/10 text-purple-500">
|
||||
<Server className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{endpoint.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{endpoint.topic} • {endpoint.serverUrl.replace(/^https?:\/\//, '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auth badge */}
|
||||
{endpoint.authType !== 'none' && (
|
||||
<div className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{endpoint.authType === 'basic' ? 'Basic Auth' : 'Token'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={endpoint.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={`${endpoint.enabled ? 'Disable' : 'Enable'} endpoint ${endpoint.name}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit endpoint ${endpoint.name}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete endpoint ${endpoint.name}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ntfy Endpoint Dialog Component
|
||||
interface NtfyEndpointDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingEndpoint: NtfyEndpointConfig | null;
|
||||
onSave: (endpoint: NtfyEndpointConfig) => void;
|
||||
}
|
||||
|
||||
function NtfyEndpointDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingEndpoint,
|
||||
onSave,
|
||||
}: NtfyEndpointDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [serverUrl, setServerUrl] = useState('https://ntfy.sh');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [authType, setAuthType] = useState<NtfyAuthenticationType>('none');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [defaultTags, setDefaultTags] = useState('');
|
||||
const [defaultEmoji, setDefaultEmoji] = useState('');
|
||||
const [defaultClickUrl, setDefaultClickUrl] = useState('');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editingEndpoint) {
|
||||
setName(editingEndpoint.name);
|
||||
setServerUrl(editingEndpoint.serverUrl);
|
||||
setTopic(editingEndpoint.topic);
|
||||
setAuthType(editingEndpoint.authType);
|
||||
setUsername(editingEndpoint.username || '');
|
||||
setPassword(''); // Don't populate password for security
|
||||
setToken(''); // Don't populate token for security
|
||||
setDefaultTags(editingEndpoint.defaultTags || '');
|
||||
setDefaultEmoji(editingEndpoint.defaultEmoji || '');
|
||||
setDefaultClickUrl(editingEndpoint.defaultClickUrl || '');
|
||||
setEnabled(editingEndpoint.enabled);
|
||||
} else {
|
||||
setName('');
|
||||
setServerUrl('https://ntfy.sh');
|
||||
setTopic('');
|
||||
setAuthType('none');
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setToken('');
|
||||
setDefaultTags('');
|
||||
setDefaultEmoji('');
|
||||
setDefaultClickUrl('');
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
}, [open, editingEndpoint]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmedPassword = password.trim();
|
||||
const trimmedToken = token.trim();
|
||||
const endpoint: NtfyEndpointConfig = {
|
||||
id: editingEndpoint?.id || generateUUID(),
|
||||
name: name.trim(),
|
||||
serverUrl: serverUrl.trim(),
|
||||
topic: topic.trim(),
|
||||
authType,
|
||||
username: authType === 'basic' ? username.trim() : undefined,
|
||||
// Preserve existing secret if input was left blank when editing
|
||||
password:
|
||||
authType === 'basic'
|
||||
? trimmedPassword || (editingEndpoint ? editingEndpoint.password : undefined)
|
||||
: undefined,
|
||||
token:
|
||||
authType === 'token'
|
||||
? trimmedToken || (editingEndpoint ? editingEndpoint.token : undefined)
|
||||
: undefined,
|
||||
defaultTags: defaultTags.trim() || undefined,
|
||||
defaultEmoji: defaultEmoji.trim() || undefined,
|
||||
defaultClickUrl: defaultClickUrl.trim() || undefined,
|
||||
enabled,
|
||||
};
|
||||
|
||||
onSave(endpoint);
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const isServerUrlValid = (() => {
|
||||
const trimmed = serverUrl.trim();
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const isValid =
|
||||
name.trim().length > 0 &&
|
||||
isServerUrlValid &&
|
||||
topic.trim().length > 0 &&
|
||||
!topic.includes(' ') &&
|
||||
(authType !== 'basic' ||
|
||||
(username.trim().length > 0 &&
|
||||
(password.trim().length > 0 || Boolean(editingEndpoint?.password)))) &&
|
||||
(authType !== 'token' || token.trim().length > 0 || Boolean(editingEndpoint?.token));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingEndpoint ? 'Edit Ntfy Endpoint' : 'Add Ntfy Endpoint'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure an ntfy.sh server to receive push notifications.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-name">Name *</Label>
|
||||
<Input
|
||||
id="endpoint-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Personal Phone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-server">Server URL</Label>
|
||||
<Input
|
||||
id="endpoint-server"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="https://ntfy.sh"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default is ntfy.sh. Use custom URL for self-hosted servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Topic */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-topic">Topic *</Label>
|
||||
<Input
|
||||
id="endpoint-topic"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="my-automaker-notifications"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Topic name (no spaces). This acts like a channel for your notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-auth">Authentication</Label>
|
||||
<Select
|
||||
value={authType}
|
||||
onValueChange={(v) => setAuthType(v as NtfyAuthenticationType)}
|
||||
>
|
||||
<SelectTrigger id="endpoint-auth">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (public topic)</SelectItem>
|
||||
<SelectItem value="basic">Username & Password</SelectItem>
|
||||
<SelectItem value="token">Access Token</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Conditional auth fields */}
|
||||
{authType === 'basic' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-username">Username</Label>
|
||||
<Input
|
||||
id="endpoint-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-password">Password</Label>
|
||||
<Input
|
||||
id="endpoint-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === 'token' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-token">Access Token</Label>
|
||||
<Input
|
||||
id="endpoint-token"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="tk_xxxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-tags">Default Tags (optional)</Label>
|
||||
<Input
|
||||
id="endpoint-tags"
|
||||
value={defaultTags}
|
||||
onChange={(e) => setDefaultTags(e.target.value)}
|
||||
placeholder="warning,skull"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated tags or emoji shortcodes (e.g., warning, partypopper)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default Emoji */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-emoji">Default Emoji (optional)</Label>
|
||||
<Input
|
||||
id="endpoint-emoji"
|
||||
value={defaultEmoji}
|
||||
onChange={(e) => setDefaultEmoji(e.target.value)}
|
||||
placeholder="tada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Click URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-click">Default Click URL (optional)</Label>
|
||||
<Input
|
||||
id="endpoint-click"
|
||||
value={defaultClickUrl}
|
||||
onChange={(e) => setDefaultClickUrl(e.target.value)}
|
||||
placeholder="http://localhost:3007"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL to open when notification is clicked. Auto-linked to project/feature if available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="endpoint-enabled">Enabled</Label>
|
||||
<Switch id="endpoint-enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid}>
|
||||
{editingEndpoint ? 'Save Changes' : 'Add Endpoint'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
ideationModel: 'Ideation',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
prDescriptionModel: 'PR Description',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
@@ -580,7 +580,7 @@ export function PhaseModelSelector({
|
||||
id: model.id,
|
||||
label: model.name,
|
||||
description: model.description,
|
||||
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
|
||||
badge: model.tier === 'premium' ? 'Premium' : undefined,
|
||||
provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
|
||||
isGitRepo: true,
|
||||
hasCommits: true,
|
||||
trackingRemote: result.result?.trackingRemote,
|
||||
remotesWithBranch: result.result?.remotesWithBranch,
|
||||
remotesWithBranch: (result.result as { remotesWithBranch?: string[] })?.remotesWithBranch,
|
||||
};
|
||||
},
|
||||
enabled: !!worktreePath,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useAgentOutputWebSocket({
|
||||
onFeatureComplete,
|
||||
}: UseAgentOutputWebSocketProps) {
|
||||
const [streamedContent, setStreamedContent] = useState('');
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Use React Query for initial output loading
|
||||
const { data: initialOutput = '', isLoading } = useAgentOutput(projectPath, featureId, {
|
||||
@@ -98,7 +98,16 @@ export function useAgentOutputWebSocket({
|
||||
if (isBacklogPlan) {
|
||||
// Handle backlog plan events
|
||||
if (api.backlogPlan) {
|
||||
unsubscribe = api.backlogPlan.onEvent(handleBacklogPlanEvent);
|
||||
unsubscribe = api.backlogPlan.onEvent((data: unknown) => {
|
||||
if (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'type' in data &&
|
||||
typeof (data as { type: unknown }).type === 'string'
|
||||
) {
|
||||
handleBacklogPlanEvent(data as BacklogPlanEvent);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Handle auto mode events
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
migratePhaseModelEntry,
|
||||
type GlobalSettings,
|
||||
type CursorModelId,
|
||||
type PhaseModelEntry,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsMigration');
|
||||
@@ -198,6 +199,8 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||
ntfyEndpoints: state.ntfyEndpoints as GlobalSettings['ntfyEndpoints'],
|
||||
featureTemplates: state.featureTemplates as GlobalSettings['featureTemplates'],
|
||||
projects: state.projects as GlobalSettings['projects'],
|
||||
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
||||
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
||||
@@ -809,6 +812,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
mcpServers: settings.mcpServers ?? [],
|
||||
promptCustomization: settings.promptCustomization ?? {},
|
||||
eventHooks: settings.eventHooks ?? [],
|
||||
ntfyEndpoints: settings.ntfyEndpoints ?? [],
|
||||
featureTemplates: settings.featureTemplates ?? [],
|
||||
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
|
||||
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
||||
@@ -821,7 +826,10 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
agentModelBySession: settings.agentModelBySession
|
||||
? Object.fromEntries(
|
||||
Object.entries(settings.agentModelBySession as Record<string, unknown>).map(
|
||||
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
|
||||
([sessionId, entry]) => [
|
||||
sessionId,
|
||||
migratePhaseModelEntry(entry as string | PhaseModelEntry | null | undefined),
|
||||
]
|
||||
)
|
||||
)
|
||||
: current.agentModelBySession,
|
||||
@@ -945,6 +953,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
eventHooks: state.eventHooks,
|
||||
ntfyEndpoints: state.ntfyEndpoints,
|
||||
featureTemplates: state.featureTemplates,
|
||||
claudeCompatibleProviders: state.claudeCompatibleProviders,
|
||||
claudeApiProfiles: state.claudeApiProfiles,
|
||||
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type CursorModelId,
|
||||
type GeminiModelId,
|
||||
type CopilotModelId,
|
||||
type PhaseModelEntry,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
@@ -106,6 +107,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'subagentsSources',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
'ntfyEndpoints',
|
||||
'featureTemplates',
|
||||
'claudeCompatibleProviders', // Claude-compatible provider configs - must persist to server
|
||||
'claudeApiProfiles',
|
||||
@@ -855,7 +857,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
agentModelBySession: serverSettings.agentModelBySession
|
||||
? Object.fromEntries(
|
||||
Object.entries(serverSettings.agentModelBySession as Record<string, unknown>).map(
|
||||
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
|
||||
([sessionId, entry]) => [
|
||||
sessionId,
|
||||
migratePhaseModelEntry(entry as string | PhaseModelEntry | null | undefined),
|
||||
]
|
||||
)
|
||||
)
|
||||
: currentAppState.agentModelBySession,
|
||||
@@ -870,6 +875,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
recentFolders: serverSettings.recentFolders ?? [],
|
||||
// Event hooks
|
||||
eventHooks: serverSettings.eventHooks ?? [],
|
||||
// Ntfy endpoints
|
||||
ntfyEndpoints: serverSettings.ntfyEndpoints ?? [],
|
||||
// Feature templates
|
||||
featureTemplates: serverSettings.featureTemplates ?? [],
|
||||
// Codex CLI Settings
|
||||
|
||||
@@ -239,6 +239,8 @@ export interface RunningAgent {
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
branchName?: string;
|
||||
|
||||
@@ -1384,7 +1384,7 @@ export function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
|
||||
// Check for the presence of phase headers with separator
|
||||
const hasMultiplePhases =
|
||||
summary.includes(PHASE_SEPARATOR) && summary.match(/###\s+.+/g)?.length > 0;
|
||||
summary.includes(PHASE_SEPARATOR) && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
|
||||
|
||||
return hasMultiplePhases;
|
||||
}
|
||||
|
||||
@@ -101,12 +101,22 @@ export function getProviderFromModel(model?: string): ModelProvider {
|
||||
|
||||
/**
|
||||
* Get display name for a model
|
||||
* Handles both aliases (e.g., "sonnet") and full model IDs (e.g., "claude-sonnet-4-20250514")
|
||||
*/
|
||||
export function getModelDisplayName(model: ModelAlias | string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
// Claude aliases
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
// Claude canonical IDs (without version suffix)
|
||||
'claude-haiku': 'Claude Haiku',
|
||||
'claude-sonnet': 'Claude Sonnet',
|
||||
'claude-opus': 'Claude Opus',
|
||||
// Claude full model IDs (returned by server)
|
||||
'claude-haiku-4-5': 'Claude Haiku',
|
||||
'claude-sonnet-4-20250514': 'Claude Sonnet',
|
||||
'claude-opus-4-6': 'Claude Opus',
|
||||
// Codex models
|
||||
'codex-gpt-5.2': 'GPT-5.2',
|
||||
'codex-gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
|
||||
@@ -211,3 +221,24 @@ export function generateUUID(): string {
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 0) return date.toLocaleDateString();
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
|
||||
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
|
||||
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ if (isDev) {
|
||||
// Must be set before app.whenReady() — has no effect on macOS/Windows.
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||
// Link the running process to its .desktop file so GNOME/KDE uses the
|
||||
// desktop entry's Icon for the taskbar instead of Electron's default.
|
||||
// Must be called before any window is created.
|
||||
app.setDesktopName('automaker.desktop');
|
||||
}
|
||||
|
||||
// Register IPC handlers
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createLazyFileRoute } from '@tanstack/react-router';
|
||||
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { BoardView } from '@/components/views/board-view';
|
||||
|
||||
export const Route = createLazyFileRoute('/board')({
|
||||
component: BoardView,
|
||||
component: BoardRouteComponent,
|
||||
});
|
||||
|
||||
function BoardRouteComponent() {
|
||||
const { featureId } = useSearch({ from: '/board' });
|
||||
return <BoardView initialFeatureId={featureId} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Search params schema for board route
|
||||
const boardSearchSchema = z.object({
|
||||
featureId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Component is lazy-loaded via board.lazy.tsx for code splitting.
|
||||
// Board is the most-visited landing route, but lazy loading still benefits
|
||||
@@ -6,4 +12,6 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
// downloaded when the user actually navigates to /board (vs being bundled
|
||||
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
|
||||
// dynamic import automatically when a .lazy.tsx file exists.
|
||||
export const Route = createFileRoute('/board')({});
|
||||
export const Route = createFileRoute('/board')({
|
||||
validateSearch: boardSearchSchema,
|
||||
});
|
||||
|
||||
@@ -361,6 +361,7 @@ const initialState: AppState = {
|
||||
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
|
||||
promptCustomization: {},
|
||||
eventHooks: [],
|
||||
ntfyEndpoints: [],
|
||||
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
|
||||
claudeCompatibleProviders: [],
|
||||
claudeApiProfiles: [],
|
||||
@@ -1501,6 +1502,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Ntfy Endpoint actions
|
||||
setNtfyEndpoints: async (endpoints) => {
|
||||
set({ ntfyEndpoints: endpoints });
|
||||
try {
|
||||
const httpApi = getHttpApiClient();
|
||||
await httpApi.settings.updateGlobal({ ntfyEndpoints: endpoints });
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync ntfy endpoints:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Feature Template actions
|
||||
setFeatureTemplates: async (templates) => {
|
||||
set({ featureTemplates: templates });
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ModelDefinition,
|
||||
ServerLogLevel,
|
||||
EventHook,
|
||||
NtfyEndpointConfig,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
SidebarStyle,
|
||||
@@ -275,6 +276,9 @@ export interface AppState {
|
||||
// Event Hooks
|
||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||
|
||||
// Ntfy.sh Notification Endpoints
|
||||
ntfyEndpoints: NtfyEndpointConfig[]; // Configured ntfy.sh endpoints for push notifications
|
||||
|
||||
// Feature Templates
|
||||
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
|
||||
|
||||
@@ -675,6 +679,9 @@ export interface AppActions {
|
||||
// Event Hook actions
|
||||
setEventHooks: (hooks: EventHook[]) => Promise<void>;
|
||||
|
||||
// Ntfy Endpoint actions
|
||||
setNtfyEndpoints: (endpoints: NtfyEndpointConfig[]) => Promise<void>;
|
||||
|
||||
// Feature Template actions
|
||||
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
|
||||
addFeatureTemplate: (template: FeatureTemplate) => Promise<void>;
|
||||
|
||||
6
apps/ui/src/types/electron.d.ts
vendored
6
apps/ui/src/types/electron.d.ts
vendored
@@ -1433,10 +1433,14 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Subscribe to dev server log events (started, output, stopped, url-detected)
|
||||
// Subscribe to dev server log events (starting, started, output, stopped, url-detected)
|
||||
onDevServerLogEvent: (
|
||||
callback: (
|
||||
event:
|
||||
| {
|
||||
type: 'dev-server:starting';
|
||||
payload: { worktreePath: string; timestamp: string };
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:started';
|
||||
payload: { worktreePath: string; port: number; url: string; timestamp: string };
|
||||
|
||||
@@ -85,15 +85,8 @@ test.describe('Agent Chat Session', () => {
|
||||
const sessionCount = await countSessionItems(page);
|
||||
expect(sessionCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Ensure the new session is selected (click first session item if message list not yet visible)
|
||||
// Handles race where list updates before selection is applied in CI
|
||||
// Verify the message list is visible (indicates the newly created session was selected)
|
||||
const messageList = page.locator('[data-testid="message-list"]');
|
||||
const sessionItem = page.locator('[data-testid^="session-item-"]').first();
|
||||
if (!(await messageList.isVisible())) {
|
||||
await sessionItem.click();
|
||||
}
|
||||
|
||||
// Verify the message list is visible (indicates a session is selected)
|
||||
await expect(messageList).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the agent input is visible
|
||||
|
||||
176
apps/ui/tests/features/feature-deep-link.spec.ts
Normal file
176
apps/ui/tests/features/feature-deep-link.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Feature Deep Link E2E Test
|
||||
*
|
||||
* Tests that navigating to /board?featureId=xxx opens the board and shows
|
||||
* the output modal for the specified feature.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
createTempDirPath,
|
||||
cleanupTempDir,
|
||||
setupRealProject,
|
||||
waitForNetworkIdle,
|
||||
clickAddFeature,
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
authenticateForTests,
|
||||
handleLoginScreenIfPresent,
|
||||
waitForAgentOutputModal,
|
||||
getOutputModalDescription,
|
||||
} from '../utils';
|
||||
|
||||
const TEST_TEMP_DIR = createTempDirPath('feature-deep-link-test');
|
||||
|
||||
test.describe('Feature Deep Link', () => {
|
||||
let projectPath: string;
|
||||
let projectName: string;
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
projectName = `test-project-${testInfo.workerIndex}-${Date.now()}`;
|
||||
projectPath = path.join(TEST_TEMP_DIR, projectName);
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectPath, 'package.json'),
|
||||
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
|
||||
);
|
||||
|
||||
const automakerDir = path.join(projectPath, '.automaker');
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
|
||||
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(automakerDir, 'categories.json'),
|
||||
JSON.stringify({ categories: [] }, null, 2)
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(automakerDir, 'app_spec.txt'),
|
||||
`# ${projectName}\n\nA test project for e2e testing.`
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (projectPath && fs.existsSync(projectPath)) {
|
||||
fs.rmSync(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test('should open output modal when navigating to /board?featureId=xxx', async ({ page }) => {
|
||||
const featureDescription = `Deep link test feature ${Date.now()}`;
|
||||
|
||||
// Setup project
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
const authOk = await authenticateForTests(page);
|
||||
expect(authOk).toBe(true);
|
||||
|
||||
// Create a feature first
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Create a feature
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, featureDescription);
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the feature to appear in the backlog
|
||||
await expect(async () => {
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const featureCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({
|
||||
hasText: featureDescription,
|
||||
});
|
||||
expect(await featureCard.count()).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 20000 });
|
||||
|
||||
// Get the feature ID from the card
|
||||
const featureCard = page
|
||||
.locator('[data-testid="kanban-column-backlog"]')
|
||||
.locator('[data-testid^="kanban-card-"]')
|
||||
.filter({ hasText: featureDescription })
|
||||
.first();
|
||||
const cardTestId = await featureCard.getAttribute('data-testid');
|
||||
const featureId = cardTestId?.replace('kanban-card-', '') || null;
|
||||
expect(featureId).toBeTruthy();
|
||||
|
||||
// Close any open modals first
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
if (await modal.isVisible()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(modal).toBeHidden({ timeout: 3000 });
|
||||
}
|
||||
|
||||
// Now navigate to the board with the featureId query parameter
|
||||
await page.goto(`/board?featureId=${encodeURIComponent(featureId ?? '')}`);
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// The output modal should automatically open
|
||||
await waitForAgentOutputModal(page, { timeout: 10000 });
|
||||
const modalVisible = await page.locator('[data-testid="agent-output-modal"]').isVisible();
|
||||
expect(modalVisible).toBe(true);
|
||||
|
||||
// Verify the modal shows the correct feature
|
||||
const modalDescription = await getOutputModalDescription(page);
|
||||
expect(modalDescription).toContain(featureDescription);
|
||||
});
|
||||
|
||||
test('should handle invalid featureId gracefully', async ({ page }) => {
|
||||
// Setup project
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
const authOk2 = await authenticateForTests(page);
|
||||
expect(authOk2).toBe(true);
|
||||
|
||||
// Navigate with a non-existent feature ID
|
||||
const nonExistentId = 'non-existent-feature-id-12345';
|
||||
await page.goto(`/board?featureId=${nonExistentId}`);
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Board should still load
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Output modal should NOT appear (feature doesn't exist)
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
await expect(modal).toBeHidden({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('should handle navigation without featureId', async ({ page }) => {
|
||||
// Setup project
|
||||
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
|
||||
const authOk3 = await authenticateForTests(page);
|
||||
expect(authOk3).toBe(true);
|
||||
|
||||
// Navigate without featureId
|
||||
await page.goto('/board');
|
||||
await page.waitForLoadState('load');
|
||||
await handleLoginScreenIfPresent(page);
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Board should load normally
|
||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Output modal should NOT appear
|
||||
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||
await expect(modal).toBeHidden({ timeout: 2000 });
|
||||
});
|
||||
});
|
||||
271
apps/ui/tests/settings/event-hooks-settings.spec.ts
Normal file
271
apps/ui/tests/settings/event-hooks-settings.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Event Hooks Settings Page Tests
|
||||
*
|
||||
* Tests for the event hooks settings section, including:
|
||||
* - Event hooks management
|
||||
* - Ntfy endpoint configuration
|
||||
* - Dialog state management (useEffect hook validation)
|
||||
*
|
||||
* This test also serves as a regression test for the bug where
|
||||
* useEffect was not imported in the event-hooks-section.tsx file,
|
||||
* causing a runtime error when opening the Ntfy endpoint dialog.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { authenticateForTests, navigateToSettings } from '../utils';
|
||||
|
||||
// Timeout constants for maintainability
|
||||
const TIMEOUTS = {
|
||||
sectionVisible: 10000,
|
||||
dialogVisible: 5000,
|
||||
dialogHidden: 5000,
|
||||
endpointVisible: 5000,
|
||||
} as const;
|
||||
|
||||
// Selectors for reuse
|
||||
const SELECTORS = {
|
||||
eventHooksButton: 'button:has-text("Event Hooks")',
|
||||
endpointsTab: 'button[role="tab"]:has-text("Endpoints")',
|
||||
sectionText: 'text=Run custom commands or send notifications',
|
||||
addEndpointButton: 'button:has-text("Add Endpoint")',
|
||||
dialog: '[role="dialog"]',
|
||||
dialogTitle: 'text=Add Ntfy Endpoint',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Navigate to the Event Hooks Endpoints tab
|
||||
* This helper reduces code duplication across tests
|
||||
*/
|
||||
async function navigateToEndpointsTab(page: Page): Promise<void> {
|
||||
await navigateToSettings(page);
|
||||
|
||||
// Click on the Event Hooks section in the navigation
|
||||
await page.locator(SELECTORS.eventHooksButton).first().click();
|
||||
|
||||
// Wait for the event hooks section to be visible
|
||||
await expect(page.locator(SELECTORS.sectionText)).toBeVisible({
|
||||
timeout: TIMEOUTS.sectionVisible,
|
||||
});
|
||||
|
||||
// Switch to Endpoints tab (ntfy endpoints)
|
||||
await page.locator(SELECTORS.endpointsTab).click();
|
||||
}
|
||||
|
||||
test.describe('Event Hooks Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await authenticateForTests(page);
|
||||
});
|
||||
|
||||
test('should load event hooks settings section without errors', async ({ page }) => {
|
||||
await navigateToSettings(page);
|
||||
|
||||
// Click on the Event Hooks section in the navigation
|
||||
await page.locator(SELECTORS.eventHooksButton).first().click();
|
||||
|
||||
// Wait for the event hooks section to be visible
|
||||
await expect(page.locator(SELECTORS.sectionText)).toBeVisible({
|
||||
timeout: TIMEOUTS.sectionVisible,
|
||||
});
|
||||
|
||||
// Verify the tabs are present
|
||||
await expect(page.locator('button[role="tab"]:has-text("Hooks")')).toBeVisible();
|
||||
await expect(page.locator(SELECTORS.endpointsTab)).toBeVisible();
|
||||
await expect(page.locator('button[role="tab"]:has-text("History")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open add ntfy endpoint dialog and verify useEffect resets form', async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test specifically validates that the useEffect hook in NtfyEndpointDialog
|
||||
// works correctly - if useEffect was not imported, the form would not reset
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// Click Add Endpoint button
|
||||
await page.locator(SELECTORS.addEndpointButton).click();
|
||||
|
||||
// Dialog should be visible
|
||||
const dialog = page.locator(SELECTORS.dialog);
|
||||
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
|
||||
|
||||
// Dialog title should indicate adding new endpoint
|
||||
await expect(dialog.locator(SELECTORS.dialogTitle)).toBeVisible();
|
||||
|
||||
// Form should have default values (useEffect reset)
|
||||
// This is the critical test - if useEffect was not imported or not working,
|
||||
// these assertions would fail because the form state would not be reset
|
||||
const nameInput = dialog.locator('input#endpoint-name');
|
||||
const serverUrlInput = dialog.locator('input#endpoint-server');
|
||||
const topicInput = dialog.locator('input#endpoint-topic');
|
||||
|
||||
// Name should be empty (reset by useEffect)
|
||||
await expect(nameInput).toHaveValue('');
|
||||
// Server URL should have default value (reset by useEffect)
|
||||
await expect(serverUrlInput).toHaveValue('https://ntfy.sh');
|
||||
// Topic should be empty (reset by useEffect)
|
||||
await expect(topicInput).toHaveValue('');
|
||||
|
||||
// Close the dialog
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
|
||||
});
|
||||
|
||||
test('should open and close endpoint dialog without JavaScript errors', async ({ page }) => {
|
||||
// This test verifies the dialog opens without throwing a "useEffect is not defined" error
|
||||
// Listen for console errors
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// Open and close the dialog multiple times to stress test the useEffect
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.locator(SELECTORS.addEndpointButton).click();
|
||||
const dialog = page.locator(SELECTORS.dialog);
|
||||
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
|
||||
}
|
||||
|
||||
// Verify no React hook related errors occurred
|
||||
// This catches "useEffect is not defined", "useState is not defined", etc.
|
||||
const reactHookError = consoleErrors.find(
|
||||
(error) =>
|
||||
(error.includes('useEffect') ||
|
||||
error.includes('useState') ||
|
||||
error.includes('useCallback')) &&
|
||||
error.includes('is not defined')
|
||||
);
|
||||
expect(reactHookError).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should have enabled toggle working in endpoint dialog', async ({ page }) => {
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// Click Add Endpoint button
|
||||
await page.locator(SELECTORS.addEndpointButton).click();
|
||||
|
||||
const dialog = page.locator(SELECTORS.dialog);
|
||||
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
|
||||
|
||||
// Verify the enabled switch exists and is checked by default (useEffect sets enabled=true)
|
||||
const enabledSwitch = dialog.locator('#endpoint-enabled');
|
||||
await expect(enabledSwitch).toBeChecked();
|
||||
|
||||
// Click the switch to toggle it off
|
||||
await enabledSwitch.click();
|
||||
await expect(enabledSwitch).not.toBeChecked();
|
||||
|
||||
// Click it again to toggle it back on
|
||||
await enabledSwitch.click();
|
||||
await expect(enabledSwitch).toBeChecked();
|
||||
|
||||
// Close the dialog
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('should have Add Endpoint button disabled when form is invalid', async ({ page }) => {
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// Click Add Endpoint button
|
||||
await page.locator(SELECTORS.addEndpointButton).click();
|
||||
|
||||
const dialog = page.locator(SELECTORS.dialog);
|
||||
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
|
||||
|
||||
// The Add Endpoint button should be disabled because form is empty (name and topic required)
|
||||
const addButton = dialog.locator('button:has-text("Add Endpoint")').last();
|
||||
await expect(addButton).toBeDisabled();
|
||||
|
||||
// Fill in name but not topic
|
||||
await dialog.locator('input#endpoint-name').fill('Test Name');
|
||||
|
||||
// Button should still be disabled (topic is required)
|
||||
await expect(addButton).toBeDisabled();
|
||||
|
||||
// Fill in topic with invalid value (contains space)
|
||||
await dialog.locator('input#endpoint-topic').fill('invalid topic');
|
||||
|
||||
// Button should still be disabled (topic has space which is invalid)
|
||||
await expect(addButton).toBeDisabled();
|
||||
|
||||
// Fix the topic
|
||||
await dialog.locator('input#endpoint-topic').fill('valid-topic');
|
||||
|
||||
// Now button should be enabled
|
||||
await expect(addButton).toBeEnabled();
|
||||
|
||||
// Close the dialog
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('should persist ntfy endpoint after adding and page reload', async ({ page }) => {
|
||||
// This test verifies that ntfy endpoints are correctly saved to the server
|
||||
// and restored when the page is reloaded - the core bug fix being tested
|
||||
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// Add a new endpoint
|
||||
await page.locator(SELECTORS.addEndpointButton).click();
|
||||
|
||||
const dialog = page.locator(SELECTORS.dialog);
|
||||
await expect(dialog).toBeVisible({ timeout: TIMEOUTS.dialogVisible });
|
||||
|
||||
// Fill in the endpoint form
|
||||
const uniqueSuffix = Date.now();
|
||||
await dialog.locator('input#endpoint-name').fill(`Test Endpoint ${uniqueSuffix}`);
|
||||
await dialog.locator('input#endpoint-server').fill('https://ntfy.sh');
|
||||
await dialog.locator('input#endpoint-topic').fill(`test-topic-${uniqueSuffix}`);
|
||||
|
||||
// Save the endpoint
|
||||
const addButton = dialog.locator('button:has-text("Add Endpoint")').last();
|
||||
await addButton.click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(dialog).toBeHidden({ timeout: TIMEOUTS.dialogHidden });
|
||||
|
||||
// Wait for the endpoint to appear in the list
|
||||
await expect(page.locator(`text=Test Endpoint ${uniqueSuffix}`)).toBeVisible({
|
||||
timeout: TIMEOUTS.endpointVisible,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Re-authenticate after reload
|
||||
await authenticateForTests(page);
|
||||
|
||||
// Navigate back to the endpoints tab
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// Verify the endpoint persisted after reload
|
||||
await expect(page.locator(`text=Test Endpoint ${uniqueSuffix}`)).toBeVisible({
|
||||
timeout: TIMEOUTS.sectionVisible,
|
||||
});
|
||||
});
|
||||
|
||||
test('should display existing endpoints on initial load', async ({ page }) => {
|
||||
// This test verifies that any existing endpoints are displayed when the page first loads
|
||||
// Navigate to the page and check if we can see the endpoints section
|
||||
|
||||
await navigateToEndpointsTab(page);
|
||||
|
||||
// The endpoints tab should show either existing endpoints or the empty state
|
||||
// The key is that it should NOT show "empty" if there are endpoints on the server
|
||||
|
||||
// Either we see "No endpoints configured" OR we see endpoint cards
|
||||
const emptyState = page.locator('text=No endpoints configured');
|
||||
const endpointCard = page.locator('[data-testid="endpoint-card"]').first();
|
||||
|
||||
// One of these should be visible
|
||||
await expect(
|
||||
Promise.race([
|
||||
emptyState.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'empty'),
|
||||
endpointCard.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'card'),
|
||||
])
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -172,8 +172,13 @@ export type {
|
||||
EventHookHttpMethod,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
EventHookAction,
|
||||
EventHook,
|
||||
EventHookContext,
|
||||
// Ntfy notification types
|
||||
NtfyAuthenticationType,
|
||||
NtfyEndpointConfig,
|
||||
// Feature template types
|
||||
FeatureTemplate,
|
||||
// Claude-compatible provider types (new)
|
||||
|
||||
@@ -12,7 +12,9 @@ export type NotificationType =
|
||||
| 'feature_waiting_approval'
|
||||
| 'feature_verified'
|
||||
| 'spec_regeneration_complete'
|
||||
| 'agent_complete';
|
||||
| 'agent_complete'
|
||||
| 'feature_error'
|
||||
| 'auto_mode_error';
|
||||
|
||||
/**
|
||||
* Notification - A single notification entry
|
||||
|
||||
@@ -747,6 +747,49 @@ export type EventHookTrigger =
|
||||
/** HTTP methods supported for webhook requests */
|
||||
export type EventHookHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
|
||||
|
||||
/**
|
||||
* NtfyAuthenticationType - Authentication methods for ntfy.sh
|
||||
*
|
||||
* - 'none': No authentication (default for public topics)
|
||||
* - 'basic': Username and password authentication
|
||||
* - 'token': Access token authentication
|
||||
*/
|
||||
export type NtfyAuthenticationType = 'none' | 'basic' | 'token';
|
||||
|
||||
/**
|
||||
* NtfyEndpointConfig - Configuration for a ntfy.sh notification endpoint
|
||||
*
|
||||
* Stores reusable ntfy.sh server configuration that can be referenced
|
||||
* by multiple event hooks. Supports custom servers (self-hosted),
|
||||
* authentication, and notification customization.
|
||||
*/
|
||||
export interface NtfyEndpointConfig {
|
||||
/** Unique identifier for this endpoint configuration */
|
||||
id: string;
|
||||
/** Display name (e.g., "Personal Phone", "Team Channel") */
|
||||
name: string;
|
||||
/** Server URL (default: https://ntfy.sh) */
|
||||
serverUrl: string;
|
||||
/** Topic name (required, no spaces) */
|
||||
topic: string;
|
||||
/** Authentication type */
|
||||
authType: NtfyAuthenticationType;
|
||||
/** Username for basic auth (required if authType === 'basic') */
|
||||
username?: string;
|
||||
/** Password for basic auth (required if authType === 'basic') */
|
||||
password?: string;
|
||||
/** Access token (required if authType === 'token') */
|
||||
token?: string;
|
||||
/** Default tags for notifications (comma-separated emoji codes) */
|
||||
defaultTags?: string;
|
||||
/** Default emoji for notifications (emoji or shortcode) */
|
||||
defaultEmoji?: string;
|
||||
/** Default click action URL (auto-populated with server URL) */
|
||||
defaultClickUrl?: string;
|
||||
/** Whether this endpoint is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventHookShellAction - Configuration for executing a shell command
|
||||
*
|
||||
@@ -778,8 +821,32 @@ export interface EventHookHttpAction {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventHookNtfyAction - Configuration for sending ntfy.sh push notifications
|
||||
*
|
||||
* Uses a pre-configured ntfy.sh endpoint from the global settings.
|
||||
* Supports variable substitution in title and body.
|
||||
*/
|
||||
export interface EventHookNtfyAction {
|
||||
type: 'ntfy';
|
||||
/** ID of the NtfyEndpointConfig to use */
|
||||
endpointId: string;
|
||||
/** Notification title (supports {{variable}} substitution, defaults to event name) */
|
||||
title?: string;
|
||||
/** Notification body/message (supports {{variable}} substitution) */
|
||||
body?: string;
|
||||
/** Tags for this specific notification (comma-separated, overrides endpoint default) */
|
||||
tags?: string;
|
||||
/** Emoji for this specific notification (overrides endpoint default) */
|
||||
emoji?: string;
|
||||
/** Click action URL (overrides endpoint default, supports {{variable}} substitution) */
|
||||
clickUrl?: string;
|
||||
/** Priority level (1=min, 3=default, 5=max/urgent) */
|
||||
priority?: 1 | 2 | 3 | 4 | 5;
|
||||
}
|
||||
|
||||
/** Union type for all hook action configurations */
|
||||
export type EventHookAction = EventHookShellAction | EventHookHttpAction;
|
||||
export type EventHookAction = EventHookShellAction | EventHookHttpAction | EventHookNtfyAction;
|
||||
|
||||
/**
|
||||
* EventHook - Configuration for a single event hook
|
||||
@@ -818,6 +885,31 @@ export const EVENT_HOOK_TRIGGER_LABELS: Record<EventHookTrigger, string> = {
|
||||
auto_mode_error: 'Auto mode paused due to error',
|
||||
};
|
||||
|
||||
/**
|
||||
* EventHookContext - Context variables available for substitution in event hooks
|
||||
*
|
||||
* These variables can be used in shell commands, HTTP bodies, and ntfy notifications
|
||||
* using the {{variableName}} syntax.
|
||||
*/
|
||||
export interface EventHookContext {
|
||||
/** ID of the feature (if applicable) */
|
||||
featureId?: string;
|
||||
/** Title/name of the feature (if applicable) */
|
||||
featureName?: string;
|
||||
/** Absolute path to the project */
|
||||
projectPath?: string;
|
||||
/** Name of the project (derived from path) */
|
||||
projectName?: string;
|
||||
/** Error message (only for error events) */
|
||||
error?: string;
|
||||
/** Error type/classification (only for error events) */
|
||||
errorType?: string;
|
||||
/** ISO timestamp when the event occurred */
|
||||
timestamp: string;
|
||||
/** The event type that triggered the hook */
|
||||
eventType: EventHookTrigger;
|
||||
}
|
||||
|
||||
const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false;
|
||||
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
|
||||
const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request';
|
||||
@@ -1398,6 +1490,14 @@ export interface GlobalSettings {
|
||||
*/
|
||||
eventHooks?: EventHook[];
|
||||
|
||||
// Ntfy.sh Notification Endpoints
|
||||
/**
|
||||
* Configured ntfy.sh notification endpoints for push notifications.
|
||||
* These endpoints can be referenced by event hooks to send notifications.
|
||||
* @see NtfyEndpointConfig for configuration details
|
||||
*/
|
||||
ntfyEndpoints?: NtfyEndpointConfig[];
|
||||
|
||||
// Feature Templates Configuration
|
||||
/**
|
||||
* Feature templates for quick task creation from the Add Feature dropdown
|
||||
@@ -1823,6 +1923,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
subagentsSources: ['user', 'project'],
|
||||
// Event hooks
|
||||
eventHooks: [],
|
||||
// Ntfy.sh notification endpoints
|
||||
ntfyEndpoints: [],
|
||||
// Feature templates
|
||||
featureTemplates: DEFAULT_FEATURE_TEMPLATES,
|
||||
// New provider system
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "automaker",
|
||||
"version": "0.15.0",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"engines": {
|
||||
|
||||
1
test/fixtures/projectA
vendored
1
test/fixtures/projectA
vendored
Submodule test/fixtures/projectA deleted from e2bcc1c966
Reference in New Issue
Block a user