feat: implement export tasks (#1260)
This commit is contained in:
@@ -51,7 +51,8 @@ export const ERROR_CODES = {
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
INVALID_INPUT: 'INVALID_INPUT',
|
||||
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
NOT_FOUND: 'NOT_FOUND'
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
@@ -11,7 +11,9 @@ export {
|
||||
type ListTasksResult,
|
||||
type StartTaskOptions,
|
||||
type StartTaskResult,
|
||||
type ConflictCheckResult
|
||||
type ConflictCheckResult,
|
||||
type ExportTasksOptions,
|
||||
type ExportResult
|
||||
} from './task-master-core.js';
|
||||
|
||||
// Re-export types
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
import type { Task, TaskMetadata, TaskStatus } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Options for loading tasks from storage
|
||||
*/
|
||||
export interface LoadTasksOptions {
|
||||
/** Filter tasks by status */
|
||||
status?: TaskStatus;
|
||||
/** Exclude subtasks from loaded tasks (default: false) */
|
||||
excludeSubtasks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for updateTaskStatus operations
|
||||
*/
|
||||
@@ -21,11 +31,12 @@ export interface UpdateStatusResult {
|
||||
*/
|
||||
export interface IStorage {
|
||||
/**
|
||||
* Load all tasks from storage, optionally filtered by tag
|
||||
* Load all tasks from storage, optionally filtered by tag and other criteria
|
||||
* @param tag - Optional tag to filter tasks by
|
||||
* @param options - Optional filtering options (status, excludeSubtasks)
|
||||
* @returns Promise that resolves to an array of tasks
|
||||
*/
|
||||
loadTasks(tag?: string): Promise<Task[]>;
|
||||
loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]>;
|
||||
|
||||
/**
|
||||
* Load a single task by ID
|
||||
@@ -205,7 +216,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete classes
|
||||
abstract loadTasks(tag?: string): Promise<Task[]>;
|
||||
abstract loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]>;
|
||||
abstract loadTask(taskId: string, tag?: string): Promise<Task | null>;
|
||||
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TaskWithRelations,
|
||||
TaskDatabaseUpdate
|
||||
} from '../../types/repository-types.js';
|
||||
import { LoadTasksOptions } from '../../interfaces/storage.interface.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Zod schema for task status validation
|
||||
@@ -56,11 +57,14 @@ export class SupabaseTaskRepository {
|
||||
return context.briefId;
|
||||
}
|
||||
|
||||
async getTasks(_projectId?: string): Promise<Task[]> {
|
||||
async getTasks(
|
||||
_projectId?: string,
|
||||
options?: LoadTasksOptions
|
||||
): Promise<Task[]> {
|
||||
const briefId = this.getBriefIdOrThrow();
|
||||
|
||||
// Get all tasks for the brief using the exact query structure
|
||||
const { data: tasks, error } = await this.supabase
|
||||
// Build query with filters
|
||||
let query = this.supabase
|
||||
.from('tasks')
|
||||
.select(`
|
||||
*,
|
||||
@@ -71,7 +75,22 @@ export class SupabaseTaskRepository {
|
||||
description
|
||||
)
|
||||
`)
|
||||
.eq('brief_id', briefId)
|
||||
.eq('brief_id', briefId);
|
||||
|
||||
// Apply status filter at database level if specified
|
||||
if (options?.status) {
|
||||
const dbStatus = this.mapStatusToDatabase(options.status);
|
||||
query = query.eq('status', dbStatus);
|
||||
}
|
||||
|
||||
// Apply subtask exclusion at database level if specified
|
||||
if (options?.excludeSubtasks) {
|
||||
// Only fetch parent tasks (where parent_task_id is null)
|
||||
query = query.is('parent_task_id', null);
|
||||
}
|
||||
|
||||
// Execute query with ordering
|
||||
const { data: tasks, error } = await query
|
||||
.order('position', { ascending: true })
|
||||
.order('subtask_position', { ascending: true })
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Task, TaskTag } from '../types/index.js';
|
||||
import { LoadTasksOptions } from '../interfaces/storage.interface.js';
|
||||
|
||||
export interface TaskRepository {
|
||||
// Task operations
|
||||
getTasks(projectId: string): Promise<Task[]>;
|
||||
getTasks(projectId: string, options?: LoadTasksOptions): Promise<Task[]>;
|
||||
getTask(projectId: string, taskId: string): Promise<Task | null>;
|
||||
createTask(projectId: string, task: Omit<Task, 'id'>): Promise<Task>;
|
||||
updateTask(
|
||||
|
||||
496
packages/tm-core/src/services/export.service.ts
Normal file
496
packages/tm-core/src/services/export.service.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* @fileoverview Export Service
|
||||
* Core service for exporting tasks to external systems (e.g., Hamster briefs)
|
||||
*/
|
||||
|
||||
import type { Task, TaskStatus } from '../types/index.js';
|
||||
import type { UserContext } from '../auth/types.js';
|
||||
import { ConfigManager } from '../config/config-manager.js';
|
||||
import { AuthManager } from '../auth/auth-manager.js';
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
import { FileStorage } from '../storage/file-storage/index.js';
|
||||
|
||||
// Type definitions for the bulk API response
|
||||
interface TaskImportResult {
|
||||
externalId?: string;
|
||||
index: number;
|
||||
success: boolean;
|
||||
taskId?: string;
|
||||
error?: string;
|
||||
validationErrors?: string[];
|
||||
}
|
||||
|
||||
interface BulkTasksResponse {
|
||||
dryRun: boolean;
|
||||
totalTasks: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
skippedCount: number;
|
||||
results: TaskImportResult[];
|
||||
summary: {
|
||||
message: string;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for exporting tasks
|
||||
*/
|
||||
export interface ExportTasksOptions {
|
||||
/** Optional tag to export tasks from (uses active tag if not provided) */
|
||||
tag?: string;
|
||||
/** Brief ID to export to */
|
||||
briefId?: string;
|
||||
/** Organization ID (required if briefId is provided) */
|
||||
orgId?: string;
|
||||
/** Filter by task status */
|
||||
status?: TaskStatus;
|
||||
/** Exclude subtasks from export (default: false, subtasks included by default) */
|
||||
excludeSubtasks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the export operation
|
||||
*/
|
||||
export interface ExportResult {
|
||||
/** Whether the export was successful */
|
||||
success: boolean;
|
||||
/** Number of tasks exported */
|
||||
taskCount: number;
|
||||
/** The brief ID tasks were exported to */
|
||||
briefId: string;
|
||||
/** The organization ID */
|
||||
orgId: string;
|
||||
/** Optional message */
|
||||
message?: string;
|
||||
/** Error details if export failed */
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Brief information from API
|
||||
*/
|
||||
export interface Brief {
|
||||
id: string;
|
||||
accountId: string;
|
||||
createdAt: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExportService handles task export to external systems
|
||||
*/
|
||||
export class ExportService {
|
||||
private configManager: ConfigManager;
|
||||
private authManager: AuthManager;
|
||||
|
||||
constructor(configManager: ConfigManager, authManager: AuthManager) {
|
||||
this.configManager = configManager;
|
||||
this.authManager = authManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export tasks to a brief
|
||||
*/
|
||||
async exportTasks(options: ExportTasksOptions): Promise<ExportResult> {
|
||||
// Validate authentication
|
||||
if (!this.authManager.isAuthenticated()) {
|
||||
throw new TaskMasterError(
|
||||
'Authentication required for export',
|
||||
ERROR_CODES.AUTHENTICATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Get current context
|
||||
const context = this.authManager.getContext();
|
||||
|
||||
// Determine org and brief IDs
|
||||
let orgId = options.orgId || context?.orgId;
|
||||
let briefId = options.briefId || context?.briefId;
|
||||
|
||||
// Validate we have necessary IDs
|
||||
if (!orgId) {
|
||||
throw new TaskMasterError(
|
||||
'Organization ID is required for export. Use "tm context org" to select one.',
|
||||
ERROR_CODES.MISSING_CONFIGURATION
|
||||
);
|
||||
}
|
||||
|
||||
if (!briefId) {
|
||||
throw new TaskMasterError(
|
||||
'Brief ID is required for export. Use "tm context brief" or provide --brief flag.',
|
||||
ERROR_CODES.MISSING_CONFIGURATION
|
||||
);
|
||||
}
|
||||
|
||||
// Get tasks from the specified or active tag
|
||||
const activeTag = this.configManager.getActiveTag();
|
||||
const tag = options.tag || activeTag;
|
||||
|
||||
// Always read tasks from local file storage for export
|
||||
// (we're exporting local tasks to a remote brief)
|
||||
const fileStorage = new FileStorage(this.configManager.getProjectRoot());
|
||||
await fileStorage.initialize();
|
||||
|
||||
// Load tasks with filters applied at storage layer
|
||||
const filteredTasks = await fileStorage.loadTasks(tag, {
|
||||
status: options.status,
|
||||
excludeSubtasks: options.excludeSubtasks
|
||||
});
|
||||
|
||||
// Get total count (without filters) for comparison
|
||||
const allTasks = await fileStorage.loadTasks(tag);
|
||||
|
||||
const taskListResult = {
|
||||
tasks: filteredTasks,
|
||||
total: allTasks.length,
|
||||
filtered: filteredTasks.length,
|
||||
tag,
|
||||
storageType: 'file' as const
|
||||
};
|
||||
|
||||
if (taskListResult.tasks.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
taskCount: 0,
|
||||
briefId,
|
||||
orgId,
|
||||
message: 'No tasks found to export',
|
||||
error: {
|
||||
code: 'NO_TASKS',
|
||||
message: 'No tasks match the specified criteria'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the export API with the original tasks
|
||||
// performExport will handle the transformation based on the method used
|
||||
await this.performExport(orgId, briefId, taskListResult.tasks);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskCount: taskListResult.tasks.length,
|
||||
briefId,
|
||||
orgId,
|
||||
message: `Successfully exported ${taskListResult.tasks.length} task(s) to brief`
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
taskCount: 0,
|
||||
briefId,
|
||||
orgId,
|
||||
error: {
|
||||
code: 'EXPORT_FAILED',
|
||||
message: errorMessage
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export tasks from a brief ID or URL
|
||||
*/
|
||||
async exportFromBriefInput(briefInput: string): Promise<ExportResult> {
|
||||
// Extract brief ID from input
|
||||
const briefId = this.extractBriefId(briefInput);
|
||||
if (!briefId) {
|
||||
throw new TaskMasterError(
|
||||
'Invalid brief ID or URL provided',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch brief to get organization
|
||||
const brief = await this.authManager.getBrief(briefId);
|
||||
if (!brief) {
|
||||
throw new TaskMasterError(
|
||||
'Brief not found or you do not have access',
|
||||
ERROR_CODES.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
// Export with the resolved org and brief
|
||||
return this.exportTasks({
|
||||
orgId: brief.accountId,
|
||||
briefId: brief.id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate export context before prompting
|
||||
*/
|
||||
async validateContext(): Promise<{
|
||||
hasOrg: boolean;
|
||||
hasBrief: boolean;
|
||||
context: UserContext | null;
|
||||
}> {
|
||||
const context = this.authManager.getContext();
|
||||
|
||||
return {
|
||||
hasOrg: !!context?.orgId,
|
||||
hasBrief: !!context?.briefId,
|
||||
context
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform tasks for API bulk import format (flat structure)
|
||||
*/
|
||||
private transformTasksForBulkImport(tasks: Task[]): any[] {
|
||||
const flatTasks: any[] = [];
|
||||
|
||||
// Process each task and its subtasks
|
||||
tasks.forEach((task) => {
|
||||
// Add parent task
|
||||
flatTasks.push({
|
||||
externalId: String(task.id),
|
||||
title: task.title,
|
||||
description: this.enrichDescription(task),
|
||||
status: this.mapStatusForAPI(task.status),
|
||||
priority: task.priority || 'medium',
|
||||
dependencies: task.dependencies?.map(String) || [],
|
||||
details: task.details,
|
||||
testStrategy: task.testStrategy,
|
||||
complexity: task.complexity,
|
||||
metadata: {
|
||||
complexity: task.complexity,
|
||||
originalId: task.id,
|
||||
originalDescription: task.description,
|
||||
originalDetails: task.details,
|
||||
originalTestStrategy: task.testStrategy
|
||||
}
|
||||
});
|
||||
|
||||
// Add subtasks if they exist
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
task.subtasks.forEach((subtask) => {
|
||||
flatTasks.push({
|
||||
externalId: `${task.id}.${subtask.id}`,
|
||||
parentExternalId: String(task.id),
|
||||
title: subtask.title,
|
||||
description: this.enrichDescription(subtask),
|
||||
status: this.mapStatusForAPI(subtask.status),
|
||||
priority: subtask.priority || 'medium',
|
||||
dependencies:
|
||||
subtask.dependencies?.map((dep) => {
|
||||
// Convert subtask dependencies to full ID format
|
||||
if (String(dep).includes('.')) {
|
||||
return String(dep);
|
||||
}
|
||||
return `${task.id}.${dep}`;
|
||||
}) || [],
|
||||
details: subtask.details,
|
||||
testStrategy: subtask.testStrategy,
|
||||
complexity: subtask.complexity,
|
||||
metadata: {
|
||||
complexity: subtask.complexity,
|
||||
originalId: subtask.id,
|
||||
originalDescription: subtask.description,
|
||||
originalDetails: subtask.details,
|
||||
originalTestStrategy: subtask.testStrategy
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return flatTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich task/subtask description with implementation details and test strategy
|
||||
* Creates a comprehensive markdown-formatted description
|
||||
*/
|
||||
private enrichDescription(taskOrSubtask: Task | any): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
// Start with original description if it exists
|
||||
if (taskOrSubtask.description) {
|
||||
sections.push(taskOrSubtask.description);
|
||||
}
|
||||
|
||||
// Add implementation details section
|
||||
if (taskOrSubtask.details) {
|
||||
sections.push('## Implementation Details\n');
|
||||
sections.push(taskOrSubtask.details);
|
||||
}
|
||||
|
||||
// Add test strategy section
|
||||
if (taskOrSubtask.testStrategy) {
|
||||
sections.push('## Test Strategy\n');
|
||||
sections.push(taskOrSubtask.testStrategy);
|
||||
}
|
||||
|
||||
// Join sections with double newlines for better markdown formatting
|
||||
return sections.join('\n\n').trim() || 'No description provided';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map internal status to API status format
|
||||
*/
|
||||
private mapStatusForAPI(status?: string): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'todo';
|
||||
case 'in-progress':
|
||||
return 'in_progress';
|
||||
case 'done':
|
||||
return 'done';
|
||||
default:
|
||||
return 'todo';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual export API call
|
||||
*/
|
||||
private async performExport(
|
||||
orgId: string,
|
||||
briefId: string,
|
||||
tasks: any[]
|
||||
): Promise<void> {
|
||||
// Check if we should use the API endpoint or direct Supabase
|
||||
const useAPIEndpoint = process.env.TM_PUBLIC_BASE_DOMAIN;
|
||||
|
||||
if (useAPIEndpoint) {
|
||||
// Use the new bulk import API endpoint
|
||||
const apiUrl = `${process.env.TM_PUBLIC_BASE_DOMAIN}/ai/api/v1/briefs/${briefId}/tasks/bulk`;
|
||||
|
||||
// Transform tasks to flat structure for API
|
||||
const flatTasks = this.transformTasksForBulkImport(tasks);
|
||||
|
||||
// Prepare request body
|
||||
const requestBody = {
|
||||
source: 'task-master-cli',
|
||||
accountId: orgId,
|
||||
options: {
|
||||
dryRun: false,
|
||||
stopOnError: false
|
||||
},
|
||||
tasks: flatTasks
|
||||
};
|
||||
|
||||
// Get auth token
|
||||
const credentials = this.authManager.getCredentials();
|
||||
if (!credentials || !credentials.token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// Make API request
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${credentials.token}`
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`API request failed: ${response.status} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as BulkTasksResponse;
|
||||
|
||||
if (result.failedCount > 0) {
|
||||
const failedTasks = result.results
|
||||
.filter((r) => !r.success)
|
||||
.map((r) => `${r.externalId}: ${r.error}`)
|
||||
.join(', ');
|
||||
console.warn(
|
||||
`Warning: ${result.failedCount} tasks failed to import: ${failedTasks}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Successfully exported ${result.successCount} of ${result.totalTasks} tasks to brief ${briefId}`
|
||||
);
|
||||
} else {
|
||||
// Direct Supabase approach is no longer supported
|
||||
// The extractTasks method has been removed from SupabaseTaskRepository
|
||||
// as we now exclusively use the API endpoint for exports
|
||||
throw new Error(
|
||||
'Export API endpoint not configured. Please set TM_PUBLIC_BASE_DOMAIN environment variable to enable task export.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a brief ID from raw input (ID or URL)
|
||||
*/
|
||||
private extractBriefId(input: string): string | null {
|
||||
const raw = input?.trim() ?? '';
|
||||
if (!raw) return null;
|
||||
|
||||
const parseUrl = (s: string): URL | null => {
|
||||
try {
|
||||
return new URL(s);
|
||||
} catch {}
|
||||
try {
|
||||
return new URL(`https://${s}`);
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fromParts = (path: string): string | null => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const briefsIdx = parts.lastIndexOf('briefs');
|
||||
const candidate =
|
||||
briefsIdx >= 0 && parts.length > briefsIdx + 1
|
||||
? parts[briefsIdx + 1]
|
||||
: parts[parts.length - 1];
|
||||
return candidate?.trim() || null;
|
||||
};
|
||||
|
||||
// Try to parse as URL
|
||||
const url = parseUrl(raw);
|
||||
if (url) {
|
||||
const qId = url.searchParams.get('id') || url.searchParams.get('briefId');
|
||||
const candidate = (qId || fromParts(url.pathname)) ?? null;
|
||||
if (candidate) {
|
||||
if (this.isLikelyId(candidate) || candidate.length >= 8) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it looks like a path without scheme
|
||||
if (raw.includes('/')) {
|
||||
const candidate = fromParts(raw);
|
||||
if (candidate && (this.isLikelyId(candidate) || candidate.length >= 8)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Return as-is if it looks like an ID
|
||||
if (this.isLikelyId(raw) || raw.length >= 8) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a brief ID (UUID-like)
|
||||
*/
|
||||
private isLikelyId(value: string): boolean {
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
|
||||
const slugRegex = /^[A-Za-z0-9_-]{16,}$/;
|
||||
return (
|
||||
uuidRegex.test(value) || ulidRegex.test(value) || slugRegex.test(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,9 @@
|
||||
|
||||
export { TaskService } from './task-service.js';
|
||||
export { OrganizationService } from './organization.service.js';
|
||||
export { ExportService } from './export.service.js';
|
||||
export type { Organization, Brief } from './organization.service.js';
|
||||
export type {
|
||||
ExportTasksOptions,
|
||||
ExportResult
|
||||
} from './export.service.js';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ConfigManager } from '../config/config-manager.js';
|
||||
import { StorageFactory } from '../storage/storage-factory.js';
|
||||
import { TaskEntity } from '../entities/task.entity.js';
|
||||
import { ERROR_CODES, TaskMasterError } from '../errors/task-master-error.js';
|
||||
import { getLogger } from '../logger/factory.js';
|
||||
|
||||
/**
|
||||
* Result returned by getTaskList
|
||||
@@ -51,6 +52,7 @@ export class TaskService {
|
||||
private configManager: ConfigManager;
|
||||
private storage: IStorage;
|
||||
private initialized = false;
|
||||
private logger = getLogger('TaskService');
|
||||
|
||||
constructor(configManager: ConfigManager) {
|
||||
this.configManager = configManager;
|
||||
@@ -90,37 +92,76 @@ export class TaskService {
|
||||
const tag = options.tag || activeTag;
|
||||
|
||||
try {
|
||||
// Load raw tasks from storage - storage only knows about tags
|
||||
const rawTasks = await this.storage.loadTasks(tag);
|
||||
// Determine if we can push filters to storage layer
|
||||
const canPushStatusFilter =
|
||||
options.filter?.status &&
|
||||
!options.filter.priority &&
|
||||
!options.filter.tags &&
|
||||
!options.filter.assignee &&
|
||||
!options.filter.search &&
|
||||
options.filter.hasSubtasks === undefined;
|
||||
|
||||
// Build storage-level options
|
||||
const storageOptions: any = {};
|
||||
|
||||
// Push status filter to storage if it's the only filter
|
||||
if (canPushStatusFilter) {
|
||||
const statuses = Array.isArray(options.filter!.status)
|
||||
? options.filter!.status
|
||||
: [options.filter!.status];
|
||||
// Only push single status to storage (multiple statuses need in-memory filtering)
|
||||
if (statuses.length === 1) {
|
||||
storageOptions.status = statuses[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Push subtask exclusion to storage
|
||||
if (options.includeSubtasks === false) {
|
||||
storageOptions.excludeSubtasks = true;
|
||||
}
|
||||
|
||||
// Load tasks from storage with pushed-down filters
|
||||
const rawTasks = await this.storage.loadTasks(tag, storageOptions);
|
||||
|
||||
// Get total count without status filters, but preserve subtask exclusion
|
||||
const baseOptions: any = {};
|
||||
if (options.includeSubtasks === false) {
|
||||
baseOptions.excludeSubtasks = true;
|
||||
}
|
||||
|
||||
const allTasks =
|
||||
storageOptions.status !== undefined
|
||||
? await this.storage.loadTasks(tag, baseOptions)
|
||||
: rawTasks;
|
||||
|
||||
// Convert to TaskEntity for business logic operations
|
||||
const taskEntities = TaskEntity.fromArray(rawTasks);
|
||||
|
||||
// Apply filters if provided
|
||||
// Apply remaining filters in-memory if needed
|
||||
let filteredEntities = taskEntities;
|
||||
if (options.filter) {
|
||||
if (options.filter && !canPushStatusFilter) {
|
||||
filteredEntities = this.applyFilters(taskEntities, options.filter);
|
||||
} else if (
|
||||
options.filter?.status &&
|
||||
Array.isArray(options.filter.status) &&
|
||||
options.filter.status.length > 1
|
||||
) {
|
||||
// Multiple statuses - filter in-memory
|
||||
filteredEntities = this.applyFilters(taskEntities, options.filter);
|
||||
}
|
||||
|
||||
// Convert back to plain objects
|
||||
let tasks = filteredEntities.map((entity) => entity.toJSON());
|
||||
|
||||
// Handle subtasks option
|
||||
if (options.includeSubtasks === false) {
|
||||
tasks = tasks.map((task) => ({
|
||||
...task,
|
||||
subtasks: []
|
||||
}));
|
||||
}
|
||||
const tasks = filteredEntities.map((entity) => entity.toJSON());
|
||||
|
||||
return {
|
||||
tasks,
|
||||
total: rawTasks.length,
|
||||
total: allTasks.length,
|
||||
filtered: filteredEntities.length,
|
||||
tag: tag, // Return the actual tag being used (either explicitly provided or active tag)
|
||||
storageType: this.getStorageType()
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get task list', error);
|
||||
throw new TaskMasterError(
|
||||
'Failed to get task list',
|
||||
ERROR_CODES.INTERNAL_ERROR,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
import type {
|
||||
IStorage,
|
||||
StorageStats,
|
||||
UpdateStatusResult
|
||||
UpdateStatusResult,
|
||||
LoadTasksOptions
|
||||
} from '../interfaces/storage.interface.js';
|
||||
import type {
|
||||
Task,
|
||||
@@ -146,7 +147,7 @@ export class ApiStorage implements IStorage {
|
||||
* Load tasks from API
|
||||
* In our system, the tag parameter represents a brief ID
|
||||
*/
|
||||
async loadTasks(tag?: string): Promise<Task[]> {
|
||||
async loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
@@ -160,9 +161,9 @@ export class ApiStorage implements IStorage {
|
||||
);
|
||||
}
|
||||
|
||||
// Load tasks from the current brief context
|
||||
// Load tasks from the current brief context with filters pushed to repository
|
||||
const tasks = await this.retryOperation(() =>
|
||||
this.repository.getTasks(this.projectId)
|
||||
this.repository.getTasks(this.projectId, options)
|
||||
);
|
||||
|
||||
// Update the tag cache with the loaded task IDs
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { Task, TaskMetadata, TaskStatus } from '../../types/index.js';
|
||||
import type {
|
||||
IStorage,
|
||||
StorageStats,
|
||||
UpdateStatusResult
|
||||
UpdateStatusResult,
|
||||
LoadTasksOptions
|
||||
} from '../../interfaces/storage.interface.js';
|
||||
import { FormatHandler } from './format-handler.js';
|
||||
import { FileOperations } from './file-operations.js';
|
||||
@@ -92,15 +93,30 @@ export class FileStorage implements IStorage {
|
||||
* Load tasks from the single tasks.json file for a specific tag
|
||||
* Enriches tasks with complexity data from the complexity report
|
||||
*/
|
||||
async loadTasks(tag?: string): Promise<Task[]> {
|
||||
async loadTasks(tag?: string, options?: LoadTasksOptions): Promise<Task[]> {
|
||||
const filePath = this.pathResolver.getTasksPath();
|
||||
const resolvedTag = tag || 'master';
|
||||
|
||||
try {
|
||||
const rawData = await this.fileOps.readJson(filePath);
|
||||
const tasks = this.formatHandler.extractTasks(rawData, resolvedTag);
|
||||
let tasks = this.formatHandler.extractTasks(rawData, resolvedTag);
|
||||
|
||||
// Apply filters if provided
|
||||
if (options) {
|
||||
// Filter by status if specified
|
||||
if (options.status) {
|
||||
tasks = tasks.filter((task) => task.status === options.status);
|
||||
}
|
||||
|
||||
// Exclude subtasks if specified
|
||||
if (options.excludeSubtasks) {
|
||||
tasks = tasks.map((task) => ({
|
||||
...task,
|
||||
subtasks: []
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich tasks with complexity data
|
||||
return await this.enrichTasksWithComplexity(tasks, resolvedTag);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
|
||||
@@ -14,7 +14,14 @@ import {
|
||||
type StartTaskResult,
|
||||
type ConflictCheckResult
|
||||
} from './services/task-execution-service.js';
|
||||
import {
|
||||
ExportService,
|
||||
type ExportTasksOptions,
|
||||
type ExportResult
|
||||
} from './services/export.service.js';
|
||||
import { AuthManager } from './auth/auth-manager.js';
|
||||
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
|
||||
import type { UserContext } from './auth/types.js';
|
||||
import type { IConfiguration } from './interfaces/configuration.interface.js';
|
||||
import type {
|
||||
Task,
|
||||
@@ -47,6 +54,10 @@ export type {
|
||||
StartTaskResult,
|
||||
ConflictCheckResult
|
||||
} from './services/task-execution-service.js';
|
||||
export type {
|
||||
ExportTasksOptions,
|
||||
ExportResult
|
||||
} from './services/export.service.js';
|
||||
|
||||
/**
|
||||
* TaskMasterCore facade class
|
||||
@@ -56,6 +67,7 @@ export class TaskMasterCore {
|
||||
private configManager: ConfigManager;
|
||||
private taskService: TaskService;
|
||||
private taskExecutionService: TaskExecutionService;
|
||||
private exportService: ExportService;
|
||||
private executorService: ExecutorService | null = null;
|
||||
|
||||
/**
|
||||
@@ -80,6 +92,7 @@ export class TaskMasterCore {
|
||||
this.configManager = null as any;
|
||||
this.taskService = null as any;
|
||||
this.taskExecutionService = null as any;
|
||||
this.exportService = null as any;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,6 +122,10 @@ export class TaskMasterCore {
|
||||
|
||||
// Create task execution service
|
||||
this.taskExecutionService = new TaskExecutionService(this.taskService);
|
||||
|
||||
// Create export service
|
||||
const authManager = AuthManager.getInstance();
|
||||
this.exportService = new ExportService(this.configManager, authManager);
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to initialize TaskMasterCore',
|
||||
@@ -242,6 +259,33 @@ export class TaskMasterCore {
|
||||
return this.taskExecutionService.getNextAvailableTask();
|
||||
}
|
||||
|
||||
// ==================== Export Service Methods ====================
|
||||
|
||||
/**
|
||||
* Export tasks to an external system (e.g., Hamster brief)
|
||||
*/
|
||||
async exportTasks(options: ExportTasksOptions): Promise<ExportResult> {
|
||||
return this.exportService.exportTasks(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export tasks from a brief ID or URL
|
||||
*/
|
||||
async exportFromBriefInput(briefInput: string): Promise<ExportResult> {
|
||||
return this.exportService.exportFromBriefInput(briefInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate export context before prompting
|
||||
*/
|
||||
async validateExportContext(): Promise<{
|
||||
hasOrg: boolean;
|
||||
hasBrief: boolean;
|
||||
context: UserContext | null;
|
||||
}> {
|
||||
return this.exportService.validateContext();
|
||||
}
|
||||
|
||||
// ==================== Executor Service Methods ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user