UNPKG

@nakedved/ano

Version:

Claude Code plugin for collaborative annotation and review

311 lines (310 loc) 12.7 kB
/** * ano check command * * Check if a file has required approvals. * Designed to be used as a Claude Code hook. * * Usage: * ano check <file> [options] * * Exit codes: * 0 = Has required approvals (or overridden) * 1 = Missing required approvals (blocks execution) * * Modes: * --soft Warn but don't block (exit 0 even if checks fail) * --override Bypass checks with a reason (logged for audit) * * Examples: * ano check plan.md # Standard check * ano check plan.md --soft # Warn only, don't block * ano check plan.md --override --reason "hotfix" # Bypass with reason */ import { Command } from 'commander'; import chalk from 'chalk'; import { resolve } from 'node:path'; import { appendFile } from 'node:fs/promises'; import { readAnnotationFile, } from '../../core/annotations.js'; import { getAuthorString } from '../../core/config.js'; import { checkTeamMembership, readTeamConfig } from '../../core/team.js'; // Create the command export const checkCommand = new Command('check') .description('Check if a file has required approvals (for hooks)') .argument('<file>', 'File to check') .option('-r, --required <count>', 'Required number of approvals (default: from team config or 1)') .option('--allow-blockers', 'Allow open blockers (don\'t fail on them)') .option('-q, --quiet', 'Suppress output (just exit code)') .option('--json', 'Output as JSON') // Title/role-based requirements .option('--require-title <titles...>', 'Require approval from users with specific titles (e.g., "Tech Lead" "Security")') .option('--require-role <roles...>', 'Require approval from team members with specific roles') // Soft gate mode .option('-s, --soft', 'Soft gate: warn but don\'t block (always exit 0)') // Override with reason .option('-o, --override', 'Override approval check (requires --reason)') .option('--reason <reason>', 'Reason for override (required with --override)') .action(async (file, options) => { try { const filePath = resolve(file); // Load team config first to get default requirements const teamConfig = await readTeamConfig(); // Priority: CLI flag > team config > default (1) let requiredCount; if (options.required !== undefined) { requiredCount = parseInt(options.required, 10); } else if (teamConfig?.requirements.minApprovals) { requiredCount = teamConfig.requirements.minApprovals; } else { requiredCount = 1; } // Handle override if (options.override) { if (!options.reason) { console.error(chalk.red('Error: --override requires --reason')); console.error(chalk.dim(' Example: ano check plan.md --override --reason "hotfix for prod"')); process.exit(1); } const author = await getAuthorString(); await logOverride(filePath, author, options.reason); if (!options.quiet) { console.log(chalk.yellow.bold(' ⚠ OVERRIDE')); console.log(); console.log(` ${chalk.bold('Author:')} ${author}`); console.log(` ${chalk.bold('Reason:')} ${options.reason}`); console.log(); console.log(chalk.dim(' Override logged to .ano-overrides.log')); } if (options.json) { console.log(JSON.stringify({ approved: true, override: true, author, reason: options.reason, file: filePath, })); } process.exit(0); } // Get current state const annotationFile = await readAnnotationFile(filePath); // If no annotation file exists, consider it "approved" (no feedback yet) if (!annotationFile) { if (options.json) { console.log(JSON.stringify({ approved: true, reason: 'No annotations exist', file: filePath, })); } else if (!options.quiet) { console.log(chalk.dim('No annotations found - proceeding')); } process.exit(0); } // Count approvals const approvals = annotationFile.approvals; const approvedCount = approvals.filter(a => a.status === 'approved').length; const changesRequested = approvals.filter(a => a.status === 'changes_requested').length; // Check for open blockers const annotations = annotationFile.annotations; const openBlockers = annotations.filter(a => a.type === 'blocker' && a.status === 'open'); // Check required titles const requiredTitles = options.requireTitle || teamConfig?.requirements.requiredTitles || []; const approvedTitles = approvals .filter(a => a.status === 'approved' && a.title) .map(a => a.title.toLowerCase()); const missingTitles = requiredTitles.filter(t => !approvedTitles.includes(t.toLowerCase())); const hasRequiredTitles = missingTitles.length === 0; // Check team membership for each approver (needed for role checks) const approversWithTeam = await Promise.all(approvals.map(async (a) => { // Try multiple formats to match team membership: // 1. "Name <email>" format - extract email // 2. Plain email "user@example.com" // 3. Plain name "ved" - try to match against team member names const emailMatch = a.author.match(/<(.+?)>/); let membership = { isMember: false }; if (emailMatch) { // Format: "Name <email>" - use email membership = await checkTeamMembership(emailMatch[1]); } else if (a.author.includes('@')) { // Format: plain email membership = await checkTeamMembership(a.author); } else if (teamConfig) { // Format: plain name - try to find by name (case-insensitive) const memberByName = teamConfig.members.find(m => m.name.toLowerCase() === a.author.toLowerCase()); if (memberByName) { membership = await checkTeamMembership(memberByName.email); } } const { isMember, member, role } = membership; return { author: a.author, title: a.title, status: a.status, isMember, role: member?.role, canOverride: role?.canOverride, }; })); // Check required roles (from team config) const requiredRoles = options.requireRole || teamConfig?.requirements.requiredRoles || []; const approverRoles = approversWithTeam .filter(a => a.status === 'approved' && a.isMember && a.role) .map(a => a.role.toLowerCase()); const missingRoles = requiredRoles.filter(r => !approverRoles.includes(r.toLowerCase())); const hasRequiredRoles = missingRoles.length === 0; // Determine pass/fail const hasEnoughApprovals = approvedCount >= requiredCount; const hasNoBlockers = options.allowBlockers ? true : openBlockers.length === 0; const noChangesRequested = changesRequested === 0; const passed = hasEnoughApprovals && hasNoBlockers && noChangesRequested && hasRequiredTitles && hasRequiredRoles; // Build result const result = { approved: passed, softMode: options.soft, file: filePath, hasTeamConfig: !!teamConfig, approvals: { required: requiredCount, received: approvedCount, changesRequested, }, blockers: { count: openBlockers.length, items: openBlockers.map(b => ({ line: b.anchor.line, author: b.author, content: b.content, })), }, titles: { required: requiredTitles, missing: missingTitles, }, roles: { required: requiredRoles, missing: missingRoles, }, approvers: approversWithTeam, }; // Output if (options.json) { console.log(JSON.stringify(result, null, 2)); } else if (!options.quiet) { printResult(result, requiredCount, options.soft); } // Exit code - soft mode always exits 0 if (options.soft) { process.exit(0); } else { process.exit(passed ? 0 : 1); } } catch (error) { if (options.json) { console.log(JSON.stringify({ approved: false, error: error.message, })); } else if (!options.quiet) { console.error(chalk.red('Error:'), error.message); } process.exit(1); } }); /** * Log override to audit file */ async function logOverride(file, author, reason) { const logEntry = JSON.stringify({ timestamp: new Date().toISOString(), file, author, reason, action: 'override', }) + '\n'; const logFile = resolve(process.cwd(), '.ano-overrides.log'); await appendFile(logFile, logEntry); } /** * Print human-readable result */ function printResult(result, requiredCount, softMode) { console.log(); // Header if (result.approved) { console.log(chalk.green.bold(' ✓ APPROVED')); } else if (softMode) { console.log(chalk.yellow.bold(' ⚠ WARNING (soft mode - proceeding anyway)')); } else { console.log(chalk.red.bold(' ✗ NOT APPROVED')); } console.log(); // Approval count const { received, changesRequested } = result.approvals; const approvalIcon = received >= requiredCount ? chalk.green('✓') : chalk.red('✗'); console.log(` ${approvalIcon} Approvals: ${received}/${requiredCount}`); // Changes requested if (changesRequested > 0) { console.log(` ${chalk.red('✗')} Changes requested: ${changesRequested}`); } // Blockers if (result.blockers.count > 0) { console.log(` ${chalk.red('✗')} Open blockers: ${result.blockers.count}`); for (const blocker of result.blockers.items) { console.log(chalk.dim(` L${blocker.line}: ${blocker.content.slice(0, 50)}...`)); } } // Missing titles if (result.titles.missing.length > 0) { console.log(` ${chalk.red('✗')} Missing title approvals: ${result.titles.missing.join(', ')}`); } else if (result.titles.required.length > 0) { console.log(` ${chalk.green('✓')} Required titles: ${result.titles.required.join(', ')}`); } // Missing roles if (result.roles.missing.length > 0) { console.log(` ${chalk.red('✗')} Missing role approvals: ${result.roles.missing.join(', ')}`); } else if (result.roles.required.length > 0) { console.log(` ${chalk.green('✓')} Required roles: ${result.roles.required.join(', ')}`); } // List approvers if (result.approvers.length > 0) { console.log(); console.log(' Reviewers:'); for (const approver of result.approvers) { const icon = approver.status === 'approved' ? chalk.green('✓') : chalk.red('✗'); const title = approver.title ? ` (${approver.title})` : ''; // Team membership info (advisory) let teamInfo = ''; if (result.hasTeamConfig) { if (approver.isMember) { const overrideTag = approver.canOverride ? ', can override' : ''; teamInfo = chalk.cyan(` [${approver.role}${overrideTag}]`); } else { teamInfo = chalk.yellow(' [not in team]'); } } console.log(` ${icon} ${approver.author}${chalk.dim(title)}${teamInfo}`); } } // Soft mode reminder if (softMode && !result.approved) { console.log(); console.log(chalk.yellow(' ⚠ Proceeding despite issues (soft mode)')); } console.log(); }