/** * Command Parser Service * * Parses chat commands from Discord, Mattermost, Slack */ import { Injectable } from "@nestjs/common"; import { CommandAction, CommandParseResult, IssueReference, ParsedCommand, } from "./command.interface"; @Injectable() export class CommandParserService { private readonly MENTION_PATTERN = /^@mosaic(?:\s+|$)/i; private readonly ISSUE_PATTERNS = { // #42 current: /^#(\d+)$/, // owner/repo#42 crossRepo: /^([a-zA-Z0-9-_]+)\/([a-zA-Z0-9-_]+)#(\d+)$/, // https://git.example.com/owner/repo/issues/42 url: /^https?:\/\/[^/]+\/([a-zA-Z0-9-_]+)\/([a-zA-Z0-9-_]+)\/issues\/(\d+)$/, }; /** * Parse a chat command */ parseCommand(message: string): CommandParseResult { // Normalize whitespace const normalized = message.trim().replace(/\s+/g, " "); // Check for @mosaic mention if (!this.MENTION_PATTERN.test(normalized)) { return { success: false, error: { message: "Commands must start with @mosaic", help: "Example: @mosaic fix #42", }, }; } // Remove @mosaic mention const withoutMention = normalized.replace(this.MENTION_PATTERN, ""); // Tokenize const tokens = withoutMention.split(" ").filter((t) => t.length > 0); if (tokens.length === 0) { return { success: false, error: { message: "No action provided", help: this.getHelpText(), }, }; } // Parse action // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const actionStr = tokens[0]!.toLowerCase(); const action = this.parseAction(actionStr); if (!action) { return { success: false, error: { message: `Unknown action: ${actionStr}`, help: this.getHelpText(), }, }; } // Parse arguments based on action const args = tokens.slice(1); return this.parseActionArguments(action, args); } /** * Parse action string to CommandAction enum */ private parseAction(action: string): CommandAction | null { const actionMap: Record = { fix: CommandAction.FIX, status: CommandAction.STATUS, cancel: CommandAction.CANCEL, retry: CommandAction.RETRY, verbose: CommandAction.VERBOSE, quiet: CommandAction.QUIET, help: CommandAction.HELP, }; return actionMap[action] ?? null; } /** * Parse arguments for a specific action */ private parseActionArguments(action: CommandAction, args: string[]): CommandParseResult { switch (action) { case CommandAction.FIX: return this.parseFixCommand(args); case CommandAction.STATUS: case CommandAction.CANCEL: case CommandAction.RETRY: case CommandAction.VERBOSE: return this.parseJobCommand(action, args); case CommandAction.QUIET: case CommandAction.HELP: return this.parseNoArgCommand(action, args); default: return { success: false, error: { message: `Unhandled action: ${String(action)}`, }, }; } } /** * Parse fix command (requires issue reference) */ private parseFixCommand(args: string[]): CommandParseResult { if (args.length === 0) { return { success: false, error: { message: "Fix command requires an issue reference", help: "Examples: @mosaic fix #42, @mosaic fix owner/repo#42, @mosaic fix https://git.example.com/owner/repo/issues/42", }, }; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const issueRef = args[0]!; const issue = this.parseIssueReference(issueRef); if (!issue) { return { success: false, error: { message: `Invalid issue reference: ${issueRef}`, help: "Valid formats: #42, owner/repo#42, or full URL", }, }; } const command: ParsedCommand = { action: CommandAction.FIX, issue, rawArgs: args, }; return { success: true, command }; } /** * Parse job commands (status, cancel, retry, verbose) */ private parseJobCommand(action: CommandAction, args: string[]): CommandParseResult { if (args.length === 0) { return { success: false, error: { message: `${action} command requires a job ID`, help: `Example: @mosaic ${action} job-123`, }, }; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const jobId = args[0]!; const command: ParsedCommand = { action, jobId, rawArgs: args, }; return { success: true, command }; } /** * Parse commands that take no arguments (quiet, help) */ private parseNoArgCommand(action: CommandAction, args: string[]): CommandParseResult { const command: ParsedCommand = { action, rawArgs: args, }; return { success: true, command }; } /** * Parse issue reference in various formats */ private parseIssueReference(ref: string): IssueReference | null { // Try current repo format: #42 const currentMatch = ref.match(this.ISSUE_PATTERNS.current); if (currentMatch) { return { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion number: parseInt(currentMatch[1]!, 10), }; } // Try cross-repo format: owner/repo#42 const crossRepoMatch = ref.match(this.ISSUE_PATTERNS.crossRepo); if (crossRepoMatch) { return { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion number: parseInt(crossRepoMatch[3]!, 10), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion owner: crossRepoMatch[1]!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion repo: crossRepoMatch[2]!, }; } // Try URL format: https://git.example.com/owner/repo/issues/42 const urlMatch = ref.match(this.ISSUE_PATTERNS.url); if (urlMatch) { return { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion number: parseInt(urlMatch[3]!, 10), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion owner: urlMatch[1]!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion repo: urlMatch[2]!, url: ref, }; } return null; } /** * Get help text for all commands */ private getHelpText(): string { return [ "Available commands:", " @mosaic fix - Start job for issue (#42, owner/repo#42, or URL)", " @mosaic status - Get job status", " @mosaic cancel - Cancel running job", " @mosaic retry - Retry failed job", " @mosaic verbose - Enable verbose logging", " @mosaic quiet - Reduce notifications", " @mosaic help - Show this help", ].join("\n"); } }