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/feature-backlog-test-*/
|
||||||
test/running-task-display-test-*/
|
test/running-task-display-test-*/
|
||||||
test/agent-output-modal-responsive-*/
|
test/agent-output-modal-responsive-*/
|
||||||
test/fixtures/.worker-*/
|
test/fixtures/
|
||||||
test/board-bg-test-*/
|
test/board-bg-test-*/
|
||||||
test/edit-feature-test-*/
|
test/edit-feature-test-*/
|
||||||
test/open-project-test-*/
|
test/open-project-test-*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/server",
|
"name": "@automaker/server",
|
||||||
"version": "0.15.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
|
|||||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||||
const FAILURE_WINDOW_MS = 60000;
|
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 {
|
export interface AutoModeConfig {
|
||||||
maxConcurrency: number;
|
maxConcurrency: number;
|
||||||
useWorktrees: boolean;
|
useWorktrees: boolean;
|
||||||
@@ -169,12 +175,23 @@ export class AutoLoopCoordinator {
|
|||||||
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
||||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||||
if (runningCount >= projectState.config.maxConcurrency) {
|
if (runningCount >= projectState.config.maxConcurrency) {
|
||||||
await this.sleep(5000, projectState.abortController.signal);
|
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||||
if (pendingFeatures.length === 0) {
|
if (pendingFeatures.length === 0) {
|
||||||
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only emit auto_mode_idle if we're truly done with all features
|
||||||
|
if (!hasInProgressFeatures) {
|
||||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||||
message: 'No pending features - auto mode idle',
|
message: 'No pending features - auto mode idle',
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -182,7 +199,8 @@ export class AutoLoopCoordinator {
|
|||||||
});
|
});
|
||||||
projectState.hasEmittedIdleEvent = true;
|
projectState.hasEmittedIdleEvent = true;
|
||||||
}
|
}
|
||||||
await this.sleep(10000, projectState.abortController.signal);
|
}
|
||||||
|
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
|
||||||
continue;
|
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 {
|
} catch {
|
||||||
if (projectState.abortController.signal.aborted) break;
|
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;
|
projectState.isRunning = false;
|
||||||
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
|
|||||||
signal?.addEventListener('abort', onAbort);
|
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,
|
EventHookTrigger,
|
||||||
EventHookShellAction,
|
EventHookShellAction,
|
||||||
EventHookHttpAction,
|
EventHookHttpAction,
|
||||||
|
EventHookNtfyAction,
|
||||||
|
NtfyEndpointConfig,
|
||||||
|
EventHookContext,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
import { ntfyService, type NtfyContext } from './ntfy-service.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const logger = createLogger('EventHooks');
|
const logger = createLogger('EventHooks');
|
||||||
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
|
|||||||
/** Default timeout for HTTP requests (10 seconds) */
|
/** Default timeout for HTTP requests (10 seconds) */
|
||||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||||
|
|
||||||
/**
|
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
|
||||||
* Context available for variable substitution in hooks
|
type HookContext = EventHookContext;
|
||||||
*/
|
|
||||||
interface HookContext {
|
|
||||||
featureId?: string;
|
|
||||||
featureName?: string;
|
|
||||||
projectPath?: string;
|
|
||||||
projectName?: string;
|
|
||||||
error?: string;
|
|
||||||
errorType?: string;
|
|
||||||
timestamp: string;
|
|
||||||
eventType: EventHookTrigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-mode event payload structure
|
* Auto-mode event payload structure
|
||||||
@@ -451,6 +444,8 @@ export class EventHookService {
|
|||||||
await this.executeShellHook(hook.action, context, hookName);
|
await this.executeShellHook(hook.action, context, hookName);
|
||||||
} else if (hook.action.type === 'http') {
|
} else if (hook.action.type === 'http') {
|
||||||
await this.executeHttpHook(hook.action, context, hookName);
|
await this.executeHttpHook(hook.action, context, hookName);
|
||||||
|
} else if (hook.action.type === 'ntfy') {
|
||||||
|
await this.executeNtfyHook(hook.action, context, hookName);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Hook "${hookName}" failed:`, 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
|
* Substitute {{variable}} placeholders in a string
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -33,6 +33,36 @@ import { pipelineService } from './pipeline-service.js';
|
|||||||
|
|
||||||
const logger = createLogger('FeatureStateManager');
|
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.
|
* FeatureStateManager handles feature status updates with persistence guarantees.
|
||||||
*
|
*
|
||||||
@@ -45,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
|
|||||||
export class FeatureStateManager {
|
export class FeatureStateManager {
|
||||||
private events: EventEmitter;
|
private events: EventEmitter;
|
||||||
private featureLoader: FeatureLoader;
|
private featureLoader: FeatureLoader;
|
||||||
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
||||||
this.events = events;
|
this.events = events;
|
||||||
this.featureLoader = featureLoader;
|
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.status = status;
|
||||||
feature.updatedAt = new Date().toISOString();
|
feature.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
// Handle justFinishedAt timestamp based on status
|
||||||
// Badge will show for 2 minutes after this timestamp
|
const shouldSetJustFinishedAt = status === 'waiting_approval';
|
||||||
if (status === 'waiting_approval') {
|
const shouldClearJustFinishedAt = status !== 'waiting_approval';
|
||||||
|
if (shouldSetJustFinishedAt) {
|
||||||
feature.justFinishedAt = new Date().toISOString();
|
feature.justFinishedAt = new Date().toISOString();
|
||||||
|
} else if (shouldClearJustFinishedAt) {
|
||||||
|
feature.justFinishedAt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize task statuses when feature is done:
|
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
|
||||||
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
if (status === 'waiting_approval' || status === 'verified') {
|
||||||
// - Do NOT mark pending tasks as completed (they were never started)
|
this.finalizeInProgressTasks(feature, featureId, status);
|
||||||
// - 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||||
@@ -193,19 +182,21 @@ export class FeatureStateManager {
|
|||||||
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
||||||
try {
|
try {
|
||||||
const notificationService = getNotificationService();
|
const notificationService = getNotificationService();
|
||||||
|
const displayName = this.getFeatureDisplayName(feature, featureId);
|
||||||
|
|
||||||
if (status === 'waiting_approval') {
|
if (status === 'waiting_approval') {
|
||||||
await notificationService.createNotification({
|
await notificationService.createNotification({
|
||||||
type: 'feature_waiting_approval',
|
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
|
||||||
title: 'Feature Ready for Review',
|
title: displayName,
|
||||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
} else if (status === 'verified') {
|
} else if (status === 'verified') {
|
||||||
await notificationService.createNotification({
|
await notificationService.createNotification({
|
||||||
type: 'feature_verified',
|
type: NOTIFICATION_TYPE_VERIFIED,
|
||||||
title: 'Feature Verified',
|
title: displayName,
|
||||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
message: NOTIFICATION_TITLE_VERIFIED,
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
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
|
* 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');
|
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
|
// Empty object overwrite guard
|
||||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||||
const nextVal = sanitizedUpdates[key] as unknown;
|
const nextVal = sanitizedUpdates[key] as unknown;
|
||||||
@@ -1023,6 +1053,8 @@ export class SettingsService {
|
|||||||
keyboardShortcuts:
|
keyboardShortcuts:
|
||||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||||
|
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
|
||||||
|
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
|
||||||
projects: (appState.projects as ProjectRef[]) || [],
|
projects: (appState.projects as ProjectRef[]) || [],
|
||||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||||
projectHistory: (appState.projectHistory as string[]) || [],
|
projectHistory: (appState.projectHistory as string[]) || [],
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
|
|||||||
projectPath: '/home/user/project',
|
projectPath: '/home/user/project',
|
||||||
projectName: 'project',
|
projectName: 'project',
|
||||||
isAutoMode: true,
|
isAutoMode: true,
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
provider: 'claude',
|
||||||
title: 'Implement login feature',
|
title: 'Implement login feature',
|
||||||
description: 'Add user authentication with OAuth',
|
description: 'Add user authentication with OAuth',
|
||||||
},
|
},
|
||||||
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
|
|||||||
projectPath: '/home/user/other-project',
|
projectPath: '/home/user/other-project',
|
||||||
projectName: 'other-project',
|
projectName: 'other-project',
|
||||||
isAutoMode: false,
|
isAutoMode: false,
|
||||||
|
model: 'codex-gpt-5.1',
|
||||||
|
provider: 'codex',
|
||||||
title: 'Fix navigation bug',
|
title: 'Fix navigation bug',
|
||||||
description: undefined,
|
description: undefined,
|
||||||
},
|
},
|
||||||
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
|
|||||||
projectPath: '/project',
|
projectPath: '/project',
|
||||||
projectName: 'project',
|
projectName: 'project',
|
||||||
isAutoMode: true,
|
isAutoMode: true,
|
||||||
|
model: undefined,
|
||||||
|
provider: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
description: undefined,
|
description: undefined,
|
||||||
},
|
},
|
||||||
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
|
|||||||
projectPath: `/project-${i}`,
|
projectPath: `/project-${i}`,
|
||||||
projectName: `project-${i}`,
|
projectName: `project-${i}`,
|
||||||
isAutoMode: i % 2 === 0,
|
isAutoMode: i % 2 === 0,
|
||||||
|
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
|
||||||
|
provider: 'claude',
|
||||||
title: `Feature ${i}`,
|
title: `Feature ${i}`,
|
||||||
description: `Description ${i}`,
|
description: `Description ${i}`,
|
||||||
}));
|
}));
|
||||||
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
|
|||||||
projectPath: '/workspace/project-alpha',
|
projectPath: '/workspace/project-alpha',
|
||||||
projectName: 'project-alpha',
|
projectName: 'project-alpha',
|
||||||
isAutoMode: true,
|
isAutoMode: true,
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
provider: 'claude',
|
||||||
title: 'Feature A',
|
title: 'Feature A',
|
||||||
description: 'In project alpha',
|
description: 'In project alpha',
|
||||||
},
|
},
|
||||||
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
|
|||||||
projectPath: '/workspace/project-beta',
|
projectPath: '/workspace/project-beta',
|
||||||
projectName: 'project-beta',
|
projectName: 'project-beta',
|
||||||
isAutoMode: false,
|
isAutoMode: false,
|
||||||
|
model: 'codex-gpt-5.1',
|
||||||
|
provider: 'codex',
|
||||||
title: 'Feature B',
|
title: 'Feature B',
|
||||||
description: 'In project beta',
|
description: 'In project beta',
|
||||||
},
|
},
|
||||||
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
|
|||||||
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
||||||
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
|
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
|
// Helper to access the private createRunAgentFn via factory creation
|
||||||
facade = AutoModeServiceFacade.create('/project', {
|
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,
|
settingsService: mockSettingsService,
|
||||||
sharedServices: {
|
sharedServices: {
|
||||||
eventBus: { emitAutoModeEvent: vi.fn() } as any,
|
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 { EventHistoryService } from '../../../src/services/event-history-service.js';
|
||||||
import type { FeatureLoader } from '../../../src/services/feature-loader.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
|
* Create a mock EventEmitter for testing
|
||||||
*/
|
*/
|
||||||
@@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & {
|
|||||||
/**
|
/**
|
||||||
* Create a mock SettingsService
|
* Create a mock SettingsService
|
||||||
*/
|
*/
|
||||||
function createMockSettingsService(hooks: unknown[] = []): SettingsService {
|
function createMockSettingsService(
|
||||||
|
hooks: unknown[] = [],
|
||||||
|
ntfyEndpoints: unknown[] = []
|
||||||
|
): SettingsService {
|
||||||
return {
|
return {
|
||||||
getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }),
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
eventHooks: hooks,
|
||||||
|
ntfyEndpoints: ntfyEndpoints,
|
||||||
|
}),
|
||||||
} as unknown as SettingsService;
|
} as unknown as SettingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +79,7 @@ describe('EventHookService', () => {
|
|||||||
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
|
let mockSettingsService: ReturnType<typeof createMockSettingsService>;
|
||||||
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
|
let mockEventHistoryService: ReturnType<typeof createMockEventHistoryService>;
|
||||||
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
|
let mockFeatureLoader: ReturnType<typeof createMockFeatureLoader>;
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new EventHookService();
|
service = new EventHookService();
|
||||||
@@ -77,10 +87,14 @@ describe('EventHookService', () => {
|
|||||||
mockSettingsService = createMockSettingsService();
|
mockSettingsService = createMockSettingsService();
|
||||||
mockEventHistoryService = createMockEventHistoryService();
|
mockEventHistoryService = createMockEventHistoryService();
|
||||||
mockFeatureLoader = createMockFeatureLoader();
|
mockFeatureLoader = createMockFeatureLoader();
|
||||||
|
// Set up mock fetch for ntfy tests
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
service.destroy();
|
service.destroy();
|
||||||
|
global.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initialize', () => {
|
describe('initialize', () => {
|
||||||
@@ -832,4 +846,628 @@ describe('EventHookService', () => {
|
|||||||
expect(storeCall.error).toBe('Feature stopped by user');
|
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 () => {
|
it('should create notification for verified status', async () => {
|
||||||
const mockNotificationService = { createNotification: vi.fn() };
|
const mockNotificationService = { createNotification: vi.fn() };
|
||||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
(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 () => {
|
it('should sync to app_spec for completed status', async () => {
|
||||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||||
data: { ...mockFeature },
|
data: { ...mockFeature },
|
||||||
@@ -1211,4 +1361,179 @@ describe('FeatureStateManager', () => {
|
|||||||
expect(callOrder).toEqual(['persist', 'emit']);
|
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 Credentials,
|
||||||
type ProjectSettings,
|
type ProjectSettings,
|
||||||
} from '@/types/settings.js';
|
} from '@/types/settings.js';
|
||||||
|
import type { NtfyEndpointConfig } from '@automaker/types';
|
||||||
|
|
||||||
describe('settings-service.ts', () => {
|
describe('settings-service.ts', () => {
|
||||||
let testDataDir: string;
|
let testDataDir: string;
|
||||||
let testProjectDir: string;
|
let testProjectDir: string;
|
||||||
let settingsService: SettingsService;
|
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 () => {
|
beforeEach(async () => {
|
||||||
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
|
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
|
||||||
testProjectDir = path.join(os.tmpdir(), `project-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');
|
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 () => {
|
it('should create data directory if it does not exist', async () => {
|
||||||
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
||||||
const newService = new SettingsService(newDataDir);
|
const newService = new SettingsService(newDataDir);
|
||||||
@@ -562,6 +722,73 @@ describe('settings-service.ts', () => {
|
|||||||
expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg');
|
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 () => {
|
it('should handle direct localStorage values', async () => {
|
||||||
const localStorageData = {
|
const localStorageData = {
|
||||||
'automaker:lastProjectDir': '/path/to/project',
|
'automaker:lastProjectDir': '/path/to/project',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@automaker/ui",
|
"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",
|
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"author": "AutoMaker Team",
|
"author": "AutoMaker Team",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"desktopName": "automaker.desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0 <23.0.0"
|
"node": ">=22.0.0 <23.0.0"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
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 { useNavigate } from '@tanstack/react-router';
|
||||||
import { useNotificationsStore } from '@/store/notifications-store';
|
import { useNotificationsStore } from '@/store/notifications-store';
|
||||||
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import type { Notification } from '@automaker/types';
|
import type { Notification } from '@automaker/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, formatRelativeTime } 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationBellProps {
|
interface NotificationBellProps {
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
@@ -86,7 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
|
|||||||
|
|
||||||
// Navigate to the relevant view based on notification type
|
// Navigate to the relevant view based on notification type
|
||||||
if (notification.featureId) {
|
if (notification.featureId) {
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board', search: { featureId: notification.featureId } });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleMarkAsRead, setPopoverOpen, navigate]
|
[handleMarkAsRead, setPopoverOpen, navigate]
|
||||||
@@ -105,6 +87,10 @@ export function NotificationBell({ projectPath }: NotificationBellProps) {
|
|||||||
return <Check className="h-4 w-4 text-green-500" />;
|
return <Check className="h-4 w-4 text-green-500" />;
|
||||||
case 'spec_regeneration_complete':
|
case 'spec_regeneration_complete':
|
||||||
return <Check className="h-4 w-4 text-blue-500" />;
|
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:
|
default:
|
||||||
return <Bell className="h-4 w-4" />;
|
return <Bell className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,8 +195,10 @@ export function SessionManager({
|
|||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName('');
|
setNewSessionName('');
|
||||||
setIsCreating(false);
|
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);
|
onSelectSession(result.session.id);
|
||||||
|
await invalidateSessions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,8 +212,10 @@ export function SessionManager({
|
|||||||
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory);
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
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);
|
onSelectSession(result.session.id);
|
||||||
|
await invalidateSessions();
|
||||||
}
|
}
|
||||||
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);
|
}, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]);
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,12 @@ const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWo
|
|||||||
|
|
||||||
const logger = createLogger('Board');
|
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 {
|
const {
|
||||||
currentProject,
|
currentProject,
|
||||||
defaultSkipTests,
|
defaultSkipTests,
|
||||||
@@ -300,6 +305,93 @@ export function BoardView() {
|
|||||||
setFeaturesWithContext,
|
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
|
// Load pipeline config when project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentProject?.path) return;
|
if (!currentProject?.path) return;
|
||||||
@@ -1988,7 +2080,10 @@ export function BoardView() {
|
|||||||
{/* Agent Output Modal */}
|
{/* Agent Output Modal */}
|
||||||
<AgentOutputModal
|
<AgentOutputModal
|
||||||
open={showOutputModal}
|
open={showOutputModal}
|
||||||
onClose={() => setShowOutputModal(false)}
|
onClose={() => {
|
||||||
|
setShowOutputModal(false);
|
||||||
|
handledFeatureIdRef.current = undefined;
|
||||||
|
}}
|
||||||
featureDescription={outputFeature?.description || ''}
|
featureDescription={outputFeature?.description || ''}
|
||||||
featureId={outputFeature?.id || ''}
|
featureId={outputFeature?.id || ''}
|
||||||
featureStatus={outputFeature?.status}
|
featureStatus={outputFeature?.status}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
Map<string, 'pending' | 'in_progress' | 'completed'>
|
Map<string, 'pending' | 'in_progress' | 'completed'>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
// Track real-time task summary updates from WebSocket events
|
// 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
|
// Track last WebSocket event timestamp to know if we're receiving real-time updates
|
||||||
const [lastWsEventTimestamp, setLastWsEventTimestamp] = useState<number | null>(null);
|
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
|
// 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
|
// Uses freshPlanSpec (from API) for accurate progress, with taskStatusMap for real-time updates
|
||||||
const isFeatureFinished = feature.status === 'waiting_approval' || feature.status === 'verified';
|
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
|
// Use freshPlanSpec if available (fetched from API), fallback to store's feature.planSpec
|
||||||
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
const planSpec = freshPlanSpec?.tasks?.length ? freshPlanSpec : feature.planSpec;
|
||||||
|
|
||||||
@@ -250,7 +254,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
|
|||||||
return {
|
return {
|
||||||
content: task.description,
|
content: task.description,
|
||||||
status: effectiveStatus,
|
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 keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||||
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
|
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
|
// Generate status groups from columnFeaturesMap
|
||||||
const statusGroups = useMemo<StatusGroup[]>(() => {
|
const statusGroups = useMemo<StatusGroup[]>(() => {
|
||||||
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
// Effective sort config: when sortNewestCardOnTop is enabled, sort by createdAt desc
|
||||||
@@ -454,7 +460,7 @@ export const ListView = memo(function ListView({
|
|||||||
>
|
>
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<ListHeader
|
<ListHeader
|
||||||
sortConfig={sortConfig}
|
sortConfig={effectiveSortConfig}
|
||||||
onSortChange={onSortChange}
|
onSortChange={onSortChange}
|
||||||
showCheckbox={isSelectionMode}
|
showCheckbox={isSelectionMode}
|
||||||
allSelected={selectionState.allSelected}
|
allSelected={selectionState.allSelected}
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
@@ -93,6 +100,7 @@ export function CreatePRDialog({
|
|||||||
|
|
||||||
// Generate description state
|
// Generate description state
|
||||||
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
|
||||||
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
|
|
||||||
// PR description model override
|
// PR description model override
|
||||||
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
|
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
|
||||||
@@ -286,6 +294,7 @@ export function CreatePRDialog({
|
|||||||
setSelectedRemote('');
|
setSelectedRemote('');
|
||||||
setSelectedTargetRemote('');
|
setSelectedTargetRemote('');
|
||||||
setIsGeneratingDescription(false);
|
setIsGeneratingDescription(false);
|
||||||
|
setIsDescriptionExpanded(false);
|
||||||
operationCompletedRef.current = false;
|
operationCompletedRef.current = false;
|
||||||
}, [defaultBaseBranch]);
|
}, [defaultBaseBranch]);
|
||||||
|
|
||||||
@@ -642,13 +651,28 @@ export function CreatePRDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="pr-body">Description</Label>
|
<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
|
<Textarea
|
||||||
id="pr-body"
|
id="pr-body"
|
||||||
placeholder="Describe the changes in this PR..."
|
placeholder="Describe the changes in this PR..."
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
className="min-h-[80px]"
|
className={isDescriptionExpanded ? 'min-h-[300px]' : 'min-h-[80px]'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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
|
* 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);
|
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);
|
const remoteBranchList = remoteBranches.get(selectedRemote);
|
||||||
if (remoteBranchList) {
|
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
|
return availableBranches
|
||||||
.filter((b) => b.isRemote && b.name.startsWith(`${selectedRemote}/`))
|
.filter((b) => b.isRemote && b.name.startsWith(prefix))
|
||||||
.map((b) => b.name);
|
.map((b) => b.name.substring(prefix.length));
|
||||||
}, [availableBranches, selectedRemote, remoteBranches]);
|
}, [availableBranches, selectedRemote, remoteBranches]);
|
||||||
|
|
||||||
// Determine if the selected base branch is a remote branch.
|
// 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")
|
// Also detect manually entered remote-style names (e.g. "origin/feature")
|
||||||
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
// so the UI shows the "Remote branch — will fetch latest" hint even when
|
||||||
// the branch isn't in the fetched availableBranches list.
|
// 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
|
// 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.
|
// and may not reflect reality — suppress the remote hint to avoid misleading the user.
|
||||||
if (branchFetchError) return false;
|
if (branchFetchError) return false;
|
||||||
|
// If a remote is explicitly selected, the branch is remote
|
||||||
|
if (selectedRemote !== 'local') return true;
|
||||||
// Check fetched branch list first
|
// Check fetched branch list first
|
||||||
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
const knownRemote = availableBranches.some((b) => b.name === baseBranch && b.isRemote);
|
||||||
if (knownRemote) return true;
|
if (knownRemote) return true;
|
||||||
@@ -295,7 +311,7 @@ export function CreateWorktreeDialog({
|
|||||||
return !isKnownLocal;
|
return !isKnownLocal;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [baseBranch, availableBranches, branchFetchError]);
|
}, [baseBranch, availableBranches, branchFetchError, selectedRemote]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!branchName.trim()) {
|
if (!branchName.trim()) {
|
||||||
@@ -334,8 +350,10 @@ export function CreateWorktreeDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD)
|
// Pass the validated baseBranch if one was selected (otherwise defaults to HEAD).
|
||||||
const effectiveBaseBranch = trimmedBaseBranch || undefined;
|
// 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);
|
const result = await api.worktree.create(projectPath, branchName, effectiveBaseBranch);
|
||||||
|
|
||||||
if (result.success && result.worktree) {
|
if (result.success && result.worktree) {
|
||||||
@@ -435,7 +453,7 @@ export function CreateWorktreeDialog({
|
|||||||
<span>Base Branch</span>
|
<span>Base Branch</span>
|
||||||
{baseBranch && !showBaseBranch && (
|
{baseBranch && !showBaseBranch && (
|
||||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono ml-1">
|
||||||
{baseBranch}
|
{qualifyRemoteBranch(selectedRemote, baseBranch) ?? baseBranch}
|
||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function WorktreeDropdownItem({
|
|||||||
className="inline-flex items-center justify-center h-4 w-4 text-amber-500"
|
className="inline-flex items-center justify-center h-4 w-4 text-amber-500"
|
||||||
title="Dev server starting..."
|
title="Dev server starting..."
|
||||||
>
|
>
|
||||||
<Spinner size="xs" variant="current" />
|
<Spinner size="xs" variant="primary" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ export function WorktreeDropdown({
|
|||||||
className="inline-flex items-center justify-center h-4 w-4 text-amber-500 shrink-0"
|
className="inline-flex items-center justify-center h-4 w-4 text-amber-500 shrink-0"
|
||||||
title="Dev server starting..."
|
title="Dev server starting..."
|
||||||
>
|
>
|
||||||
<Spinner size="xs" variant="current" />
|
<Spinner size="xs" variant="primary" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -561,7 +561,7 @@ export function WorktreeDropdown({
|
|||||||
}
|
}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingAnyDevServer}
|
isStartingAnyDevServer={isStartingAnyDevServer}
|
||||||
isDevServerStarting={isDevServerStarting(selectedWorktree)}
|
isDevServerStarting={isDevServerStarting(selectedWorktree)}
|
||||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ export function WorktreeTab({
|
|||||||
trackingRemote={trackingRemote}
|
trackingRemote={trackingRemote}
|
||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isStartingDevServer={isStartingAnyDevServer}
|
isStartingAnyDevServer={isStartingAnyDevServer}
|
||||||
isDevServerStarting={isDevServerStarting}
|
isDevServerStarting={isDevServerStarting}
|
||||||
isDevServerRunning={isDevServerRunning}
|
isDevServerRunning={isDevServerRunning}
|
||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
|
|||||||
@@ -9,28 +9,11 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardTitle } from '@/components/ui/card';
|
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 { Spinner } from '@/components/ui/spinner';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { Notification } from '@automaker/types';
|
import type { Notification } from '@automaker/types';
|
||||||
|
import { formatRelativeTime } 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationsView() {
|
export function NotificationsView() {
|
||||||
const { currentProject } = useAppStore();
|
const { currentProject } = useAppStore();
|
||||||
@@ -111,8 +94,8 @@ export function NotificationsView() {
|
|||||||
|
|
||||||
// Navigate to the relevant view based on notification type
|
// Navigate to the relevant view based on notification type
|
||||||
if (notification.featureId) {
|
if (notification.featureId) {
|
||||||
// Navigate to board view - feature will be selected
|
// Navigate to board view with feature ID to show output
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board', search: { featureId: notification.featureId } });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleMarkAsRead, navigate]
|
[handleMarkAsRead, navigate]
|
||||||
@@ -128,6 +111,10 @@ export function NotificationsView() {
|
|||||||
return <Check className="h-5 w-5 text-blue-500" />;
|
return <Check className="h-5 w-5 text-blue-500" />;
|
||||||
case 'agent_complete':
|
case 'agent_complete':
|
||||||
return <Check className="h-5 w-5 text-purple-500" />;
|
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:
|
default:
|
||||||
return <Bell className="h-5 w-5" />;
|
return <Bell className="h-5 w-5" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
|||||||
projectAnalysisModel: 'Project Analysis',
|
projectAnalysisModel: 'Project Analysis',
|
||||||
ideationModel: 'Ideation',
|
ideationModel: 'Ideation',
|
||||||
memoryExtractionModel: 'Memory Extraction',
|
memoryExtractionModel: 'Memory Extraction',
|
||||||
|
prDescriptionModel: 'PR Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
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 { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
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 { AgentOutputModal } from './board-view/dialogs/agent-output-modal';
|
||||||
import { useRunningAgents } from '@/hooks/queries';
|
import { useRunningAgents } from '@/hooks/queries';
|
||||||
import { useStopFeature } from '@/hooks/mutations';
|
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() {
|
export function RunningAgentsView() {
|
||||||
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<RunningAgent | null>(null);
|
||||||
@@ -156,15 +166,21 @@ export function RunningAgentsView() {
|
|||||||
|
|
||||||
{/* Agent info */}
|
{/* Agent info */}
|
||||||
<div className="min-w-0 flex-1">
|
<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}>
|
<span className="font-medium truncate" title={agent.title || agent.featureId}>
|
||||||
{agent.title || agent.featureId}
|
{agent.title || formatFeatureId(agent.featureId)}
|
||||||
</span>
|
</span>
|
||||||
{agent.isAutoMode && (
|
{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">
|
<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
|
AUTO
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{agent.description && (
|
{agent.description && (
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Terminal, Globe } from 'lucide-react';
|
import { Terminal, Globe, Bell } from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
EventHook,
|
EventHook,
|
||||||
EventHookTrigger,
|
EventHookTrigger,
|
||||||
EventHookHttpMethod,
|
EventHookHttpMethod,
|
||||||
EventHookShellAction,
|
EventHookShellAction,
|
||||||
EventHookHttpAction,
|
EventHookHttpAction,
|
||||||
|
EventHookNtfyAction,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface EventHookDialogProps {
|
interface EventHookDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -37,7 +40,7 @@ interface EventHookDialogProps {
|
|||||||
onSave: (hook: EventHook) => void;
|
onSave: (hook: EventHook) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionType = 'shell' | 'http';
|
type ActionType = 'shell' | 'http' | 'ntfy';
|
||||||
|
|
||||||
const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||||
'feature_created',
|
'feature_created',
|
||||||
@@ -49,7 +52,17 @@ const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
|||||||
|
|
||||||
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
|
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) {
|
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
|
||||||
|
const ntfyEndpoints = useAppStore((state) => state.ntfyEndpoints);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
|
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
|
||||||
@@ -65,6 +78,15 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
|||||||
const [headers, setHeaders] = useState('');
|
const [headers, setHeaders] = useState('');
|
||||||
const [body, setBody] = 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
|
// Reset form when dialog opens/closes or editingHook changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -72,68 +94,131 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
|||||||
// Populate form with existing hook data
|
// Populate form with existing hook data
|
||||||
setName(editingHook.name || '');
|
setName(editingHook.name || '');
|
||||||
setTrigger(editingHook.trigger);
|
setTrigger(editingHook.trigger);
|
||||||
setActionType(editingHook.action.type);
|
setActionType(editingHook.action.type as ActionType);
|
||||||
|
|
||||||
if (editingHook.action.type === 'shell') {
|
if (editingHook.action.type === 'shell') {
|
||||||
const shellAction = editingHook.action as EventHookShellAction;
|
const shellAction = editingHook.action as EventHookShellAction;
|
||||||
setCommand(shellAction.command);
|
setCommand(shellAction.command);
|
||||||
setTimeout(String(shellAction.timeout || 30000));
|
setTimeout(String(shellAction.timeout || 30000));
|
||||||
// Reset HTTP fields
|
// Reset other fields
|
||||||
setUrl('');
|
resetHttpFields();
|
||||||
setMethod('POST');
|
resetNtfyFields();
|
||||||
setHeaders('');
|
} else if (editingHook.action.type === 'http') {
|
||||||
setBody('');
|
|
||||||
} else {
|
|
||||||
const httpAction = editingHook.action as EventHookHttpAction;
|
const httpAction = editingHook.action as EventHookHttpAction;
|
||||||
setUrl(httpAction.url);
|
setUrl(httpAction.url);
|
||||||
setMethod(httpAction.method);
|
setMethod(httpAction.method);
|
||||||
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
|
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
|
||||||
setBody(httpAction.body || '');
|
setBody(httpAction.body || '');
|
||||||
// Reset shell fields
|
// Reset other fields
|
||||||
setCommand('');
|
resetShellFields();
|
||||||
setTimeout('30000');
|
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 {
|
} else {
|
||||||
// Reset to defaults for new hook
|
// Reset to defaults for new hook
|
||||||
setName('');
|
setName('');
|
||||||
setTrigger('feature_success');
|
setTrigger('feature_success');
|
||||||
setActionType('shell');
|
setActionType('shell');
|
||||||
setCommand('');
|
resetShellFields();
|
||||||
setTimeout('30000');
|
resetHttpFields();
|
||||||
setUrl('');
|
resetNtfyFields();
|
||||||
setMethod('POST');
|
|
||||||
setHeaders('');
|
|
||||||
setBody('');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, editingHook]);
|
}, [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 = () => {
|
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 = {
|
const hook: EventHook = {
|
||||||
id: editingHook?.id || generateUUID(),
|
id: editingHook?.id || generateUUID(),
|
||||||
name: name.trim() || undefined,
|
name: name.trim() || undefined,
|
||||||
trigger,
|
trigger,
|
||||||
enabled: editingHook?.enabled ?? true,
|
enabled: editingHook?.enabled ?? true,
|
||||||
action:
|
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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onSave(hook);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -179,13 +264,17 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
|||||||
<Label>Action Type</Label>
|
<Label>Action Type</Label>
|
||||||
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
|
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
|
||||||
<TabsList className="w-full">
|
<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" />
|
<Terminal className="w-4 h-4" />
|
||||||
Shell Command
|
<span className="sr-only sm:inline">Shell</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="http" className="flex-1 gap-2">
|
<TabsTrigger value="http" className="flex-1 gap-1">
|
||||||
<Globe className="w-4 h-4" />
|
<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>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -273,6 +362,139 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,63 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 { 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 { 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 { 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 { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
||||||
import { EventHookDialog } from './event-hook-dialog';
|
import { EventHookDialog } from './event-hook-dialog';
|
||||||
import { EventHistoryView } from './event-history-view';
|
import { EventHistoryView } from './event-history-view';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('EventHooks');
|
const logger = createLogger('EventHooks');
|
||||||
|
|
||||||
|
type TabType = 'hooks' | 'endpoints' | 'history';
|
||||||
|
|
||||||
export function EventHooksSection() {
|
export function EventHooksSection() {
|
||||||
const { eventHooks, setEventHooks } = useAppStore();
|
const { eventHooks, setEventHooks, ntfyEndpoints, setNtfyEndpoints } = useAppStore();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
|
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 = () => {
|
const handleAddHook = () => {
|
||||||
setEditingHook(null);
|
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
|
// Group hooks by trigger type for better organization
|
||||||
const hooksByTrigger = eventHooks.reduce(
|
const hooksByTrigger = eventHooks.reduce(
|
||||||
(acc, hook) => {
|
(acc, hook) => {
|
||||||
@@ -96,7 +186,7 @@ export function EventHooksSection() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
|
||||||
<p className="text-sm text-muted-foreground/80">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,17 +196,27 @@ export function EventHooksSection() {
|
|||||||
Add Hook
|
Add Hook
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'endpoints' && (
|
||||||
|
<Button onClick={handleAddEndpoint} size="sm" className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Endpoint
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* 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">
|
<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">
|
<TabsTrigger value="hooks" className="gap-2">
|
||||||
<Webhook className="w-4 h-4" />
|
<Webhook className="w-4 h-4" />
|
||||||
Hooks
|
Hooks
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="endpoints" className="gap-2">
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
Endpoints
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="history" className="gap-2">
|
<TabsTrigger value="history" className="gap-2">
|
||||||
<History className="w-4 h-4" />
|
<History className="w-4 h-4" />
|
||||||
History
|
History
|
||||||
@@ -148,6 +248,7 @@ export function EventHooksSection() {
|
|||||||
<HookCard
|
<HookCard
|
||||||
key={hook.id}
|
key={hook.id}
|
||||||
hook={hook}
|
hook={hook}
|
||||||
|
ntfyEndpoints={ntfyEndpoints}
|
||||||
onEdit={() => handleEditHook(hook)}
|
onEdit={() => handleEditHook(hook)}
|
||||||
onDelete={() => handleDeleteHook(hook.id)}
|
onDelete={() => handleDeleteHook(hook.id)}
|
||||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||||
@@ -166,12 +267,56 @@ export function EventHooksSection() {
|
|||||||
<p className="font-medium mb-2">Available variables:</p>
|
<p className="font-medium mb-2">Available variables:</p>
|
||||||
<code className="text-[10px] leading-relaxed">
|
<code className="text-[10px] leading-relaxed">
|
||||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
{'{{error}}'} {'{{errorType}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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 */}
|
{/* History Tab */}
|
||||||
<TabsContent value="history" className="m-0">
|
<TabsContent value="history" className="m-0">
|
||||||
<div className="p-6 pt-4">
|
<div className="p-6 pt-4">
|
||||||
@@ -180,26 +325,51 @@ export function EventHooksSection() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Dialog */}
|
{/* Hook Dialog */}
|
||||||
<EventHookDialog
|
<EventHookDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
editingHook={editingHook}
|
editingHook={editingHook}
|
||||||
onSave={handleSaveHook}
|
onSave={handleSaveHook}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Endpoint Dialog */}
|
||||||
|
<NtfyEndpointDialog
|
||||||
|
open={endpointDialogOpen}
|
||||||
|
onOpenChange={setEndpointDialogOpen}
|
||||||
|
editingEndpoint={editingEndpoint}
|
||||||
|
onSave={handleSaveEndpoint}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HookCardProps {
|
interface HookCardProps {
|
||||||
hook: EventHook;
|
hook: EventHook;
|
||||||
|
ntfyEndpoints: NtfyEndpointConfig[];
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onToggle: (enabled: boolean) => 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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -210,24 +380,27 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Type icon */}
|
{/* Type icon */}
|
||||||
<div
|
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', iconStyle)}>
|
||||||
className={cn(
|
{isShell ? (
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center',
|
<Terminal className="w-4 h-4" />
|
||||||
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
|
) : 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>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">
|
<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>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{isShell
|
{isShell
|
||||||
? (hook.action as { type: 'shell'; command: string }).command
|
? (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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,3 +422,341 @@ function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
|
|||||||
</div>
|
</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',
|
projectAnalysisModel: 'Project Analysis',
|
||||||
ideationModel: 'Ideation',
|
ideationModel: 'Ideation',
|
||||||
memoryExtractionModel: 'Memory Extraction',
|
memoryExtractionModel: 'Memory Extraction',
|
||||||
|
prDescriptionModel: 'PR Description',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ export function PhaseModelSelector({
|
|||||||
id: model.id,
|
id: model.id,
|
||||||
label: model.name,
|
label: model.name,
|
||||||
description: model.description,
|
description: model.description,
|
||||||
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
|
badge: model.tier === 'premium' ? 'Premium' : undefined,
|
||||||
provider: 'opencode' as const,
|
provider: 'opencode' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
|
|||||||
isGitRepo: true,
|
isGitRepo: true,
|
||||||
hasCommits: true,
|
hasCommits: true,
|
||||||
trackingRemote: result.result?.trackingRemote,
|
trackingRemote: result.result?.trackingRemote,
|
||||||
remotesWithBranch: result.result?.remotesWithBranch,
|
remotesWithBranch: (result.result as { remotesWithBranch?: string[] })?.remotesWithBranch,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!worktreePath,
|
enabled: !!worktreePath,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function useAgentOutputWebSocket({
|
|||||||
onFeatureComplete,
|
onFeatureComplete,
|
||||||
}: UseAgentOutputWebSocketProps) {
|
}: UseAgentOutputWebSocketProps) {
|
||||||
const [streamedContent, setStreamedContent] = useState('');
|
const [streamedContent, setStreamedContent] = useState('');
|
||||||
const closeTimeoutRef = useRef<NodeJS.Timeout>();
|
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
// Use React Query for initial output loading
|
// Use React Query for initial output loading
|
||||||
const { data: initialOutput = '', isLoading } = useAgentOutput(projectPath, featureId, {
|
const { data: initialOutput = '', isLoading } = useAgentOutput(projectPath, featureId, {
|
||||||
@@ -98,7 +98,16 @@ export function useAgentOutputWebSocket({
|
|||||||
if (isBacklogPlan) {
|
if (isBacklogPlan) {
|
||||||
// Handle backlog plan events
|
// Handle backlog plan events
|
||||||
if (api.backlogPlan) {
|
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 {
|
} else {
|
||||||
// Handle auto mode events
|
// Handle auto mode events
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
migratePhaseModelEntry,
|
migratePhaseModelEntry,
|
||||||
type GlobalSettings,
|
type GlobalSettings,
|
||||||
type CursorModelId,
|
type CursorModelId,
|
||||||
|
type PhaseModelEntry,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('SettingsMigration');
|
const logger = createLogger('SettingsMigration');
|
||||||
@@ -198,6 +199,8 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
|||||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||||
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
|
||||||
|
ntfyEndpoints: state.ntfyEndpoints as GlobalSettings['ntfyEndpoints'],
|
||||||
|
featureTemplates: state.featureTemplates as GlobalSettings['featureTemplates'],
|
||||||
projects: state.projects as GlobalSettings['projects'],
|
projects: state.projects as GlobalSettings['projects'],
|
||||||
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
||||||
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
||||||
@@ -809,6 +812,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
mcpServers: settings.mcpServers ?? [],
|
mcpServers: settings.mcpServers ?? [],
|
||||||
promptCustomization: settings.promptCustomization ?? {},
|
promptCustomization: settings.promptCustomization ?? {},
|
||||||
eventHooks: settings.eventHooks ?? [],
|
eventHooks: settings.eventHooks ?? [],
|
||||||
|
ntfyEndpoints: settings.ntfyEndpoints ?? [],
|
||||||
|
featureTemplates: settings.featureTemplates ?? [],
|
||||||
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
|
claudeCompatibleProviders: settings.claudeCompatibleProviders ?? [],
|
||||||
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
claudeApiProfiles: settings.claudeApiProfiles ?? [],
|
||||||
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
activeClaudeApiProfileId: settings.activeClaudeApiProfileId ?? null,
|
||||||
@@ -821,7 +826,10 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
agentModelBySession: settings.agentModelBySession
|
agentModelBySession: settings.agentModelBySession
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(settings.agentModelBySession as Record<string, unknown>).map(
|
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,
|
: current.agentModelBySession,
|
||||||
@@ -945,6 +953,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
|||||||
mcpServers: state.mcpServers,
|
mcpServers: state.mcpServers,
|
||||||
promptCustomization: state.promptCustomization,
|
promptCustomization: state.promptCustomization,
|
||||||
eventHooks: state.eventHooks,
|
eventHooks: state.eventHooks,
|
||||||
|
ntfyEndpoints: state.ntfyEndpoints,
|
||||||
|
featureTemplates: state.featureTemplates,
|
||||||
claudeCompatibleProviders: state.claudeCompatibleProviders,
|
claudeCompatibleProviders: state.claudeCompatibleProviders,
|
||||||
claudeApiProfiles: state.claudeApiProfiles,
|
claudeApiProfiles: state.claudeApiProfiles,
|
||||||
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
activeClaudeApiProfileId: state.activeClaudeApiProfileId,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type CursorModelId,
|
type CursorModelId,
|
||||||
type GeminiModelId,
|
type GeminiModelId,
|
||||||
type CopilotModelId,
|
type CopilotModelId,
|
||||||
|
type PhaseModelEntry,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('SettingsSync');
|
const logger = createLogger('SettingsSync');
|
||||||
@@ -106,6 +107,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'subagentsSources',
|
'subagentsSources',
|
||||||
'promptCustomization',
|
'promptCustomization',
|
||||||
'eventHooks',
|
'eventHooks',
|
||||||
|
'ntfyEndpoints',
|
||||||
'featureTemplates',
|
'featureTemplates',
|
||||||
'claudeCompatibleProviders', // Claude-compatible provider configs - must persist to server
|
'claudeCompatibleProviders', // Claude-compatible provider configs - must persist to server
|
||||||
'claudeApiProfiles',
|
'claudeApiProfiles',
|
||||||
@@ -855,7 +857,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
agentModelBySession: serverSettings.agentModelBySession
|
agentModelBySession: serverSettings.agentModelBySession
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
Object.entries(serverSettings.agentModelBySession as Record<string, unknown>).map(
|
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,
|
: currentAppState.agentModelBySession,
|
||||||
@@ -870,6 +875,8 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
recentFolders: serverSettings.recentFolders ?? [],
|
recentFolders: serverSettings.recentFolders ?? [],
|
||||||
// Event hooks
|
// Event hooks
|
||||||
eventHooks: serverSettings.eventHooks ?? [],
|
eventHooks: serverSettings.eventHooks ?? [],
|
||||||
|
// Ntfy endpoints
|
||||||
|
ntfyEndpoints: serverSettings.ntfyEndpoints ?? [],
|
||||||
// Feature templates
|
// Feature templates
|
||||||
featureTemplates: serverSettings.featureTemplates ?? [],
|
featureTemplates: serverSettings.featureTemplates ?? [],
|
||||||
// Codex CLI Settings
|
// Codex CLI Settings
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ export interface RunningAgent {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
isAutoMode: boolean;
|
isAutoMode: boolean;
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
|
|||||||
@@ -1384,7 +1384,7 @@ export function isAccumulatedSummary(summary: string | undefined): boolean {
|
|||||||
|
|
||||||
// Check for the presence of phase headers with separator
|
// Check for the presence of phase headers with separator
|
||||||
const hasMultiplePhases =
|
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;
|
return hasMultiplePhases;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,12 +101,22 @@ export function getProviderFromModel(model?: string): ModelProvider {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get display name for a model
|
* 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 {
|
export function getModelDisplayName(model: ModelAlias | string): string {
|
||||||
const displayNames: Record<string, string> = {
|
const displayNames: Record<string, string> = {
|
||||||
|
// Claude aliases
|
||||||
haiku: 'Claude Haiku',
|
haiku: 'Claude Haiku',
|
||||||
sonnet: 'Claude Sonnet',
|
sonnet: 'Claude Sonnet',
|
||||||
opus: 'Claude Opus',
|
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 models
|
||||||
'codex-gpt-5.2': 'GPT-5.2',
|
'codex-gpt-5.2': 'GPT-5.2',
|
||||||
'codex-gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
|
'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('');
|
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)}`;
|
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.
|
// Must be set before app.whenReady() — has no effect on macOS/Windows.
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
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
|
// 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';
|
import { BoardView } from '@/components/views/board-view';
|
||||||
|
|
||||||
export const Route = createLazyFileRoute('/board')({
|
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 { 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.
|
// Component is lazy-loaded via board.lazy.tsx for code splitting.
|
||||||
// Board is the most-visited landing route, but lazy loading still benefits
|
// 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
|
// downloaded when the user actually navigates to /board (vs being bundled
|
||||||
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
|
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
|
||||||
// dynamic import automatically when a .lazy.tsx file exists.
|
// 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'>,
|
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
|
||||||
promptCustomization: {},
|
promptCustomization: {},
|
||||||
eventHooks: [],
|
eventHooks: [],
|
||||||
|
ntfyEndpoints: [],
|
||||||
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
|
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
|
||||||
claudeCompatibleProviders: [],
|
claudeCompatibleProviders: [],
|
||||||
claudeApiProfiles: [],
|
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
|
// Feature Template actions
|
||||||
setFeatureTemplates: async (templates) => {
|
setFeatureTemplates: async (templates) => {
|
||||||
set({ featureTemplates: templates });
|
set({ featureTemplates: templates });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
ServerLogLevel,
|
ServerLogLevel,
|
||||||
EventHook,
|
EventHook,
|
||||||
|
NtfyEndpointConfig,
|
||||||
ClaudeApiProfile,
|
ClaudeApiProfile,
|
||||||
ClaudeCompatibleProvider,
|
ClaudeCompatibleProvider,
|
||||||
SidebarStyle,
|
SidebarStyle,
|
||||||
@@ -275,6 +276,9 @@ export interface AppState {
|
|||||||
// Event Hooks
|
// Event Hooks
|
||||||
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
eventHooks: EventHook[]; // Event hooks for custom commands or webhooks
|
||||||
|
|
||||||
|
// Ntfy.sh Notification Endpoints
|
||||||
|
ntfyEndpoints: NtfyEndpointConfig[]; // Configured ntfy.sh endpoints for push notifications
|
||||||
|
|
||||||
// Feature Templates
|
// Feature Templates
|
||||||
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
|
featureTemplates: FeatureTemplate[]; // Feature templates for quick task creation
|
||||||
|
|
||||||
@@ -675,6 +679,9 @@ export interface AppActions {
|
|||||||
// Event Hook actions
|
// Event Hook actions
|
||||||
setEventHooks: (hooks: EventHook[]) => Promise<void>;
|
setEventHooks: (hooks: EventHook[]) => Promise<void>;
|
||||||
|
|
||||||
|
// Ntfy Endpoint actions
|
||||||
|
setNtfyEndpoints: (endpoints: NtfyEndpointConfig[]) => Promise<void>;
|
||||||
|
|
||||||
// Feature Template actions
|
// Feature Template actions
|
||||||
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
|
setFeatureTemplates: (templates: FeatureTemplate[]) => Promise<void>;
|
||||||
addFeatureTemplate: (template: 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;
|
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: (
|
onDevServerLogEvent: (
|
||||||
callback: (
|
callback: (
|
||||||
event:
|
event:
|
||||||
|
| {
|
||||||
|
type: 'dev-server:starting';
|
||||||
|
payload: { worktreePath: string; timestamp: string };
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'dev-server:started';
|
type: 'dev-server:started';
|
||||||
payload: { worktreePath: string; port: number; url: string; timestamp: string };
|
payload: { worktreePath: string; port: number; url: string; timestamp: string };
|
||||||
|
|||||||
@@ -85,15 +85,8 @@ test.describe('Agent Chat Session', () => {
|
|||||||
const sessionCount = await countSessionItems(page);
|
const sessionCount = await countSessionItems(page);
|
||||||
expect(sessionCount).toBeGreaterThanOrEqual(1);
|
expect(sessionCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
// Ensure the new session is selected (click first session item if message list not yet visible)
|
// Verify the message list is visible (indicates the newly created session was selected)
|
||||||
// Handles race where list updates before selection is applied in CI
|
|
||||||
const messageList = page.locator('[data-testid="message-list"]');
|
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 });
|
await expect(messageList).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Verify the agent input is visible
|
// 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,
|
EventHookHttpMethod,
|
||||||
EventHookShellAction,
|
EventHookShellAction,
|
||||||
EventHookHttpAction,
|
EventHookHttpAction,
|
||||||
|
EventHookNtfyAction,
|
||||||
EventHookAction,
|
EventHookAction,
|
||||||
EventHook,
|
EventHook,
|
||||||
|
EventHookContext,
|
||||||
|
// Ntfy notification types
|
||||||
|
NtfyAuthenticationType,
|
||||||
|
NtfyEndpointConfig,
|
||||||
// Feature template types
|
// Feature template types
|
||||||
FeatureTemplate,
|
FeatureTemplate,
|
||||||
// Claude-compatible provider types (new)
|
// Claude-compatible provider types (new)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export type NotificationType =
|
|||||||
| 'feature_waiting_approval'
|
| 'feature_waiting_approval'
|
||||||
| 'feature_verified'
|
| 'feature_verified'
|
||||||
| 'spec_regeneration_complete'
|
| 'spec_regeneration_complete'
|
||||||
| 'agent_complete';
|
| 'agent_complete'
|
||||||
|
| 'feature_error'
|
||||||
|
| 'auto_mode_error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification - A single notification entry
|
* Notification - A single notification entry
|
||||||
|
|||||||
@@ -747,6 +747,49 @@ export type EventHookTrigger =
|
|||||||
/** HTTP methods supported for webhook requests */
|
/** HTTP methods supported for webhook requests */
|
||||||
export type EventHookHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
|
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
|
* EventHookShellAction - Configuration for executing a shell command
|
||||||
*
|
*
|
||||||
@@ -778,8 +821,32 @@ export interface EventHookHttpAction {
|
|||||||
body?: string;
|
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 */
|
/** 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
|
* 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',
|
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_AUTO_LOAD_AGENTS = false;
|
||||||
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
|
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
|
||||||
const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request';
|
const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request';
|
||||||
@@ -1398,6 +1490,14 @@ export interface GlobalSettings {
|
|||||||
*/
|
*/
|
||||||
eventHooks?: EventHook[];
|
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 Configuration
|
||||||
/**
|
/**
|
||||||
* Feature templates for quick task creation from the Add Feature dropdown
|
* Feature templates for quick task creation from the Add Feature dropdown
|
||||||
@@ -1823,6 +1923,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
subagentsSources: ['user', 'project'],
|
subagentsSources: ['user', 'project'],
|
||||||
// Event hooks
|
// Event hooks
|
||||||
eventHooks: [],
|
eventHooks: [],
|
||||||
|
// Ntfy.sh notification endpoints
|
||||||
|
ntfyEndpoints: [],
|
||||||
// Feature templates
|
// Feature templates
|
||||||
featureTemplates: DEFAULT_FEATURE_TEMPLATES,
|
featureTemplates: DEFAULT_FEATURE_TEMPLATES,
|
||||||
// New provider system
|
// New provider system
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "automaker",
|
"name": "automaker",
|
||||||
"version": "0.15.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"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