@morodomi/ait3
Version:
AIT³ Development Platform - AI + Ticket + Test + Tool driven development methodology
229 lines (228 loc) • 10.7 kB
JavaScript
import { ValidationError, TicketNotFoundError, TicketAlreadyInProgressError, TicketAlreadyCompletedError } from '../../common/errors.js';
import { IDUtils, SlugUtils } from '../../common/utils.js';
import { STYLES } from '../../common/styles.js';
import { formatTicketLocation } from '../../common/utils/location-utils.js';
export async function startTicket(args, services) {
// Input validation - ID format
if (!args.id || !IDUtils.isValidTicketId(args.id)) {
throw new ValidationError('Invalid ticket ID format. Use local format (0001) or GitHub format (#70, 70)', 'id');
}
try {
// Get ticket details first for validation
const ticket = await services.ticketService.getTicket(args.id);
if (!ticket) {
throw new TicketNotFoundError(args.id);
}
// Handle Git operations FIRST, before changing ticket status
let gitOperationSuccess = false;
let gitMessage = '';
if (services.gitService && ticket) {
try {
// Check for uncommitted changes first
const isRepo = await services.gitService.isRepository();
if (isRepo) {
const hasChanges = await services.gitService.hasUncommittedChanges();
if (hasChanges) {
throw new Error('Cannot start ticket: You have uncommitted changes. Please commit or stash them first.');
}
}
// Try to create/checkout branch
gitMessage = await handleGitOperations(args.id, ticket.title, services.gitService);
gitOperationSuccess = true;
}
catch (gitError) {
// If it's a critical error (uncommitted changes or branch creation failure), don't move ticket
if (gitError instanceof Error &&
(gitError.message.includes('uncommitted changes') ||
gitError.message.includes('Failed to create branch') ||
gitError.message.includes('Permission denied'))) {
throw gitError;
}
// For non-critical errors (like fetch failures), we'll continue
gitMessage = STYLES.warning('WARNING: Git operations failed\n') +
STYLES.muted(` ${gitError instanceof Error ? gitError.message : 'Unknown error'}\n`) +
STYLES.warning('Manual Git steps:\n') +
STYLES.muted(` git checkout -b ${generateBranchName(args.id, ticket.title)}\n`);
gitOperationSuccess = true; // Allow ticket move for non-critical errors
}
}
else {
// No GitService available, that's OK
gitOperationSuccess = true;
}
// Only move ticket if Git operations succeeded (or GitService is not available)
if (!gitOperationSuccess) {
throw new Error('Git operations failed. Ticket status not changed.');
}
// Now safe to change ticket status
await services.ticketService.startTicket(args.id);
// Generate formatted success output
const messageParts = [
STYLES.success(`SUCCESS: Started ticket #${args.id}`) + (ticket ? `: ${ticket.title}` : ''),
''
];
// Add status information
messageParts.push(STYLES.info('Details:'));
messageParts.push(` Status: ${STYLES.warning('doing')}`);
// Get updated ticket to show current location
const updatedTicket = await services.ticketService.getTicket(args.id);
if (updatedTicket) {
const locationDisplay = formatTicketLocation(updatedTicket, services.ticketService);
messageParts.push(` Location: ${STYLES.info(locationDisplay)}`);
}
messageParts.push('');
// Add Git message if available
if (gitMessage) {
messageParts.push(gitMessage);
}
else if (!services.gitService && ticket) {
// Show manual instructions if GitService is not available
const branchName = generateBranchName(args.id, ticket.title);
messageParts.push(STYLES.warning('Manual Git steps:'));
messageParts.push(STYLES.muted(` git checkout -b ${branchName}`));
messageParts.push('');
}
// Add next steps guidance with new format
messageParts.push(STYLES.info('Next Action:'));
messageParts.push(`└─ Run: ${STYLES.info(`ait3 flow plan ${args.id}`)}`);
return {
success: true,
message: messageParts.join('\n')
};
}
catch (error) {
// Error handling - convert service layer errors to appropriate CLI errors
if (error instanceof ValidationError ||
error instanceof TicketNotFoundError ||
error instanceof TicketAlreadyInProgressError ||
error instanceof TicketAlreadyCompletedError) {
throw error; // Re-throw specific errors as-is
}
// Wrap other errors with context
throw new Error(`Failed to start ticket: ${error instanceof Error ? error.message : String(error)}`);
}
}
function generateBranchName(ticketId, title) {
const slug = SlugUtils.titleToSlug(title);
return `feature/${ticketId}-${slug}`;
}
async function handleGitOperations(ticketId, title, gitService) {
const messageParts = [];
// Check if it's a Git repository
const isRepo = await gitService.isRepository();
if (!isRepo) {
return handleGitNotInitialized(ticketId, title);
}
// Note: uncommitted changes are already checked before calling this function
// Try to fetch (non-critical)
await attemptFetch(gitService, messageParts);
// Handle branch operations
const branchPattern = `feature/${ticketId}-`;
const existingBranches = await gitService.findBranches(branchPattern);
if (existingBranches.length > 0) {
await handleExistingBranches(existingBranches, gitService, messageParts);
}
else {
await handleNewBranchCreation(ticketId, title, gitService, messageParts);
}
messageParts.push('');
return messageParts.join('\n');
}
function handleGitNotInitialized(ticketId, title) {
const messageParts = [
STYLES.warning('WARNING: Git is not initialized'),
STYLES.muted(' Run these commands:'),
STYLES.muted(' git init'),
STYLES.muted(` git checkout -b ${generateBranchName(ticketId, title)}`),
''
];
return messageParts.join('\n');
}
async function attemptFetch(gitService, messageParts) {
try {
await gitService.fetch();
}
catch (fetchError) {
messageParts.push(STYLES.warning('WARNING: Warning: Could not fetch remote branches'));
messageParts.push(STYLES.muted(` ${formatErrorMessage(fetchError)}`));
messageParts.push('');
}
}
async function handleExistingBranches(existingBranches, gitService, messageParts) {
if (existingBranches.length === 1) {
await handleSingleExistingBranch(existingBranches[0], gitService, messageParts);
}
else {
await handleMultipleExistingBranches(existingBranches, gitService, messageParts);
}
}
async function handleSingleExistingBranch(branchName, gitService, messageParts) {
if (branchName.startsWith('origin/')) {
await handleRemoteBranch(branchName, gitService, messageParts);
}
else {
await handleLocalBranch(branchName, gitService, messageParts);
}
}
async function handleRemoteBranch(branchName, gitService, messageParts) {
messageParts.push(STYLES.info(`INFO: Found remote branch: ${branchName}`));
messageParts.push(STYLES.muted(' Creating local tracking branch'));
const localBranchName = branchName.replace('origin/', '');
try {
await gitService.checkout(localBranchName);
messageParts.push(STYLES.success(`SUCCESS: Created and switched to: ${localBranchName}`));
}
catch (checkoutError) {
messageParts.push(STYLES.warning('WARNING: Could not create tracking branch'));
messageParts.push(STYLES.muted(` Error: ${formatErrorMessage(checkoutError)}`));
messageParts.push(STYLES.muted(' Manual command:'));
messageParts.push(STYLES.muted(` git checkout -b ${localBranchName} ${branchName}`));
}
}
async function handleLocalBranch(branchName, gitService, messageParts) {
try {
await gitService.checkout(branchName);
messageParts.push(STYLES.success(`SUCCESS: Switched to existing branch: ${branchName}`));
}
catch (checkoutError) {
// For existing branch checkout, we can be more lenient since the branch exists
messageParts.push(STYLES.warning('WARNING: Could not switch to existing branch'));
messageParts.push(STYLES.muted(` Error: ${formatErrorMessage(checkoutError)}`));
messageParts.push(STYLES.muted(' Manual resolution required'));
// Don't throw - allow ticket move since branch exists
}
}
async function handleMultipleExistingBranches(existingBranches, gitService, messageParts) {
messageParts.push(STYLES.warning('INFO: Found multiple branches:'));
existingBranches.forEach(branch => {
messageParts.push(STYLES.muted(` - ${branch}`));
});
try {
await gitService.checkout(existingBranches[0]);
messageParts.push(STYLES.success(`SUCCESS: Switched to: ${existingBranches[0]}`));
}
catch {
messageParts.push(STYLES.warning('WARNING: Could not switch automatically'));
messageParts.push(STYLES.muted(' Choose manually:'));
messageParts.push(STYLES.muted(` git checkout ${existingBranches[0]}`));
}
}
async function handleNewBranchCreation(ticketId, title, gitService, messageParts) {
const newBranchName = generateBranchName(ticketId, title);
const currentBranch = await gitService.getCurrentBranch();
try {
await gitService.createBranch(newBranchName);
await gitService.checkout(newBranchName);
messageParts.push(STYLES.success(`SUCCESS: Created and switched to branch: ${newBranchName}`));
if (currentBranch !== 'main' && currentBranch !== 'master') {
messageParts.push(STYLES.muted(` Created from branch: ${currentBranch}`));
}
}
catch (createError) {
// Throw error to prevent ticket move
throw new Error(`Failed to create branch: ${formatErrorMessage(createError)}`);
}
}
function formatErrorMessage(error) {
return error instanceof Error ? error.message : 'Unknown error';
}