@morodomi/ait3
Version:
AIT³ Development Platform - AI + Ticket + Test + Tool driven development methodology
255 lines (254 loc) • 12.7 kB
JavaScript
import { ValidationError, TicketNotFoundError } from '../../common/errors.js';
import { STYLES } from '../../common/styles.js';
import { FLOW_MESSAGES } from '../../common/flow-messages.js';
import { SlugUtils, IDUtils } from '../../common/utils.js';
import { formatTicketDisplay } from '../../common/utils/location-utils.js';
export async function squashPhase(args, services) {
// Validate ticket ID
if (!args.ticketId || args.ticketId.trim() === '') {
throw new ValidationError(FLOW_MESSAGES.TICKET_ID_REQUIRED('SQUASH'), 'ticketId');
}
// Validate ID format
if (!IDUtils.isValidTicketId(args.ticketId)) {
throw new ValidationError('Invalid ticket ID format. Use local format (0001) or GitHub format (#70, 70)', 'ticketId');
}
const { ticketId } = args;
// Get ticket information
let ticket;
try {
const foundTicket = await services.ticketService.getTicket(ticketId);
if (!foundTicket) {
throw new TicketNotFoundError(ticketId);
}
ticket = foundTicket;
}
catch (error) {
if (error instanceof TicketNotFoundError) {
throw error;
}
throw new TicketNotFoundError(ticketId);
}
// Handle ticket completion
let statusMessage = '';
if (ticket.status === 'done') {
statusMessage = `\n${STYLES.warning('WARNING: Note: Ticket is already completed')}\n`;
}
else {
// Auto-complete the ticket if not done
try {
// If ticket is in todo, we need to start it first
if (ticket.status === 'todo') {
await services.ticketService.startTicket(ticketId);
}
await services.ticketService.completeTicket(ticketId);
statusMessage = `\n${STYLES.success('SUCCESS: Automatically completed ticket #' + ticketId)}\n`;
// Update ticket status for display
ticket.status = 'done';
// Get updated ticket for location display
const updatedTicket = await services.ticketService.getTicket(ticketId);
if (updatedTicket) {
ticket = updatedTicket;
}
}
catch (completeError) {
// If auto-complete fails, abort the squash operation
throw new Error(`Failed to auto-complete ticket: ${completeError instanceof Error ? completeError.message : 'Unknown error'}`);
}
}
// Handle dry-run mode
if (args.dryRun) {
return generateDryRunOutput(ticket, args);
}
// Generate Git command suggestions with ticket location
const ticketLocation = formatTicketDisplay(ticket, services.ticketService);
const locationInfo = `${STYLES.bold('SQUASH Phase')} for Ticket #${ticketId}: ${ticket.title}\n` +
`${STYLES.info('LOCATION: Ticket location')}: ${STYLES.info(ticketLocation)}\n`;
const suggestions = await generateGitSuggestions(ticket, args, services);
return {
success: true,
message: locationInfo + statusMessage + suggestions
};
}
function generateDryRunOutput(ticket, args) {
const sections = [];
sections.push(`${STYLES.warning('SEARCH: DRY RUN')} - Preview mode`);
sections.push(`\n${STYLES.bold('TARGET: SQUASH Phase')} - Git Command Suggestions for ticket #${ticket.id}`);
sections.push(`\n${STYLES.info('LIST: Would suggest')}:`);
if (!args.noSquash) {
sections.push('├─ Git rebase commands for squashing commits');
}
sections.push('├─ Commit message templates');
sections.push('├─ Safe push commands');
if (args.pr) {
sections.push('├─ Pull request creation commands');
}
sections.push('└─ Merge workflow commands');
sections.push(`\n${STYLES.muted('Run without --dry-run to see full suggestions')}`);
return {
success: true,
message: sections.join('\n')
};
}
async function generateGitSuggestions(ticket, args, services) {
const sections = [];
const ticketId = ticket.id;
const featureName = generateFeatureBranchName(ticket);
const commitTitle = generateCommitTitle(ticket);
// Header is now added in the main function, so we don't need it here
if (args.dryRun) {
sections.push(`\n${STYLES.warning('SEARCH: DRY RUN MODE')}`);
}
// Add related commits section
sections.push(`\n${STYLES.info('Related commits to squash')}:`);
// Try to get actual commits if GitService is available
if (services.gitService) {
try {
const isRepo = await services.gitService.isRepository();
if (isRepo) {
const commits = await services.gitService.getCommits('main');
if (commits.length > 0) {
commits.forEach((commit, index) => {
const isLast = index === commits.length - 1;
const prefix = isLast ? '└─' : '├─';
sections.push(`${prefix} ${STYLES.muted(commit.hash.substring(0, 7) + ' ' + commit.message)}`);
});
}
else {
sections.push(`└─ ${STYLES.muted('No commits found in this branch')}`);
}
}
}
catch {
// Fall back to placeholder commits
sections.push(`├─ ${STYLES.muted('xxxxxxx planning(#' + ticketId + '): design approach')}`);
sections.push(`├─ ${STYLES.muted('xxxxxxx test(#' + ticketId + '): create failing tests')}`);
sections.push(`├─ ${STYLES.muted('xxxxxxx feat(#' + ticketId + '): implement feature')}`);
sections.push(`└─ ${STYLES.muted('xxxxxxx refactor(#' + ticketId + '): optimize implementation')}`);
}
}
else {
// Show placeholder commits when GitService is not available
sections.push(`├─ ${STYLES.muted('xxxxxxx planning(#' + ticketId + '): design approach')}`);
sections.push(`├─ ${STYLES.muted('xxxxxxx test(#' + ticketId + '): create failing tests')}`);
sections.push(`├─ ${STYLES.muted('xxxxxxx feat(#' + ticketId + '): implement feature')}`);
sections.push(`└─ ${STYLES.muted('xxxxxxx refactor(#' + ticketId + '): optimize implementation')}`);
}
sections.push(`\n${STYLES.bold('LIST: Suggested Git Commands')}:`);
let stepNumber = 1;
// Step 1: Squash commits (unless --no-squash)
if (!args.noSquash) {
sections.push(`\n${STYLES.info(`## ${stepNumber}. Squash commits into logical units:`)}`);
// Add AI-friendly non-interactive commands
sections.push(`\n${STYLES.warning('AI-friendly non-interactive commands:')}`);
sections.push(`${STYLES.code('# Get commit count from main')}`);
sections.push(`${STYLES.code('COMMIT_COUNT=$(git rev-list --count main..HEAD)')}`);
sections.push(`${STYLES.code('# Reset to main and create single commit')}`);
sections.push(`${STYLES.code('git reset --soft main')}`);
sections.push(`${STYLES.code(`git commit -m "${commitTitle}"`)}`);
sections.push(`\n${STYLES.muted('OR use interactive rebase (for human execution):')}`);
sections.push(`${STYLES.code('git rebase -i main')}`);
sections.push(`${STYLES.muted('# Mark commits to squash (s) or fixup (f)')}`);
sections.push(`${STYLES.muted('# Suggested grouping:')}`);
sections.push(`${STYLES.muted(`# - Planning phase commits → "planning(#${ticketId}): ${ticket.title.toLowerCase()}"`)}`);
sections.push(`${STYLES.muted(`# - Test commits → "test(#${ticketId}): comprehensive test suite"`)}`);
sections.push(`${STYLES.muted(`# - Implementation → "${commitTitle}"`)}`);
sections.push(`${STYLES.muted(`# - Refactoring → "refactor(#${ticketId}): optimize implementation"`)}`);
stepNumber++;
}
// Step 2: Create comprehensive commit message
sections.push(`\n${STYLES.info(`## ${stepNumber}. Create comprehensive commit message:`)}`);
sections.push(STYLES.code(`git commit --amend -m "${commitTitle}"`));
sections.push('');
sections.push(generateCommitBody(ticket));
sections.push('');
sections.push(STYLES.success('SUCCESS:') + ' All tests passing');
sections.push(STYLES.success('DOCS:') + ' Docs updated');
sections.push(STYLES.success('TOOLS:') + ' No breaking changes');
stepNumber++;
// Step 3: Push to remote
sections.push(`\n${STYLES.info(`## ${stepNumber}. Push to remote:`)}`);
sections.push(`${STYLES.code(`git push origin ${featureName} --force-with-lease`)}`);
sections.push(`${STYLES.warning('WARNING: --force-with-lease ensures safe force push')}`);
stepNumber++;
// Step 4: Create Pull Request (if --pr flag)
if (args.pr) {
sections.push(`\n${STYLES.info(`## ${stepNumber}. Create Pull Request:`)}`);
sections.push(`${STYLES.code(`gh pr create --title "${commitTitle}" \\`)}`);
sections.push(`${STYLES.code(` --body "Implements final phase of AIT³ workflow for ${ticket.title}" \\`)}`);
sections.push(`${STYLES.code(' --base main')}`);
sections.push(`${STYLES.muted('# Alternative: Use GitHub web interface if gh CLI not available')}`);
stepNumber++;
}
// Step 5: After review, merge
sections.push(`\n${STYLES.info(`## ${stepNumber}. After review, merge:`)}`);
sections.push(`${STYLES.code('git checkout main')}`);
sections.push(`${STYLES.code('git pull origin main')}`);
sections.push(`${STYLES.code(`git merge --no-ff ${featureName}`)}`);
sections.push(`${STYLES.code('git push origin main')}`);
sections.push(`${STYLES.warning('WARNING: Use --no-ff to preserve feature branch history')}`);
stepNumber++;
// If PR was rejected info
sections.push(`\n${STYLES.info('TIP: If PR rejected')}:`);
sections.push(`${STYLES.code(`ait3 ticket reopen ${ticketId}`)}`);
sections.push(`${STYLES.muted('This will move ticket from done → doing')}`);
// Create structured Next Action section
sections.push(`\n${STYLES.info('Next Action')}:`);
if (!args.noSquash) {
sections.push('├─ Squash commits:');
sections.push(`│ └─ ${STYLES.code('git rebase -i main')}`);
sections.push('├─ Create final commit:');
sections.push(`│ └─ ${STYLES.code(`git commit -m "${commitTitle}"`)}`);
sections.push('├─ Push changes:');
sections.push(`│ └─ ${STYLES.code('git push --force-with-lease')}`);
sections.push('└─ Create PR or merge to main');
}
else {
sections.push('├─ Review commits (no squash)');
sections.push('├─ Push changes:');
sections.push(`│ └─ ${STYLES.code('git push')}`);
sections.push('└─ Create PR or merge to main');
}
// Safety warnings
sections.push(`\n${STYLES.warning('WARNING: Safety reminders')}:`);
sections.push(`├─ ${STYLES.muted('Review all commands before execution')}`);
sections.push(`├─ ${STYLES.muted('Ensure tests pass before merging')}`);
sections.push(`├─ ${STYLES.muted('Backup important changes')}`);
sections.push(`└─ ${STYLES.muted('Use --force-with-lease instead of --force')}`);
// Educational note
sections.push(`\n${STYLES.muted('TIP: This is the final step in AIT³ workflow: PLANNING → RED → GREEN → REFACTOR → SQUASH')}`);
return sections.join('\n');
}
function generateFeatureBranchName(ticket) {
const featureName = SlugUtils.titleToSlug(ticket.title);
return `feature/${ticket.id}-${featureName}`;
}
function generateCommitTitle(ticket) {
const title = ticket.title.toLowerCase()
.replace(/^(feat|fix|refactor|test|docs|style|chore):\s*/, ''); // Remove existing prefix if any
// Determine commit type based on labels or title
let type = 'feat';
if (ticket.labels?.includes('bug') || ticket.labels?.includes('fix')) {
type = 'fix';
}
else if (ticket.labels?.includes('refactor')) {
type = 'refactor';
}
else if (ticket.labels?.includes('test')) {
type = 'test';
}
else if (ticket.labels?.includes('docs')) {
type = 'docs';
}
return `${type}(#${ticket.id}): ${title}`;
}
function generateCommitBody(ticket) {
const lines = [];
// Create a generic description based on ticket title
const description = ticket.description || `Implements ${ticket.title.toLowerCase()}`;
lines.push(description);
if (ticket.labels && ticket.labels.length > 0) {
lines.push('');
lines.push(`Labels: ${ticket.labels.join(', ')}`);
}
return lines.join('\n');
}