Files
claude-task-master/packages/tm-core/src/services/export.service.ts

498 lines
13 KiB
TypeScript

/**
* @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 = await 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 = await 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 apiEndpoint =
process.env.TM_BASE_DOMAIN || process.env.TM_PUBLIC_BASE_DOMAIN;
if (apiEndpoint) {
// Use the new bulk import API endpoint
const apiUrl = `${apiEndpoint}/ai/api/v1/briefs/${briefId}/tasks`;
// Transform tasks to flat structure for API
const flatTasks = this.transformTasksForBulkImport(tasks);
// Prepare request body
const requestBody = {
source: 'task-master-cli',
options: {
dryRun: false,
stopOnError: false
},
accountId: orgId,
tasks: flatTasks
};
// Get auth token
const credentials = await 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)
);
}
}