Add command parsing layer for chat integration (Discord, Mattermost, Slack). Features: - Parse @mosaic commands with action dispatch - Support 3 issue reference formats: #42, owner/repo#42, full URL - Handle 7 actions: fix, status, cancel, retry, verbose, quiet, help - Comprehensive error handling with helpful messages - Case-insensitive parsing - Platform-agnostic design Implementation: - CommandParserService with tokenizer and action dispatcher - Regex-based issue reference parsing - Type-safe command structures - 24 unit tests with 100% coverage TDD approach: - RED: Wrote comprehensive tests first - GREEN: Implemented parser to pass all tests - REFACTOR: Fixed TypeScript strict mode and linting issues Quality gates passed: - ✓ Typecheck - ✓ Lint - ✓ Build - ✓ Tests (24/24 passing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
259 lines
6.9 KiB
TypeScript
259 lines
6.9 KiB
TypeScript
/**
|
|
* 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<string, CommandAction> = {
|
|
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 <issue> - Start job for issue (#42, owner/repo#42, or URL)",
|
|
" @mosaic status <job> - Get job status",
|
|
" @mosaic cancel <job> - Cancel running job",
|
|
" @mosaic retry <job> - Retry failed job",
|
|
" @mosaic verbose <job> - Enable verbose logging",
|
|
" @mosaic quiet - Reduce notifications",
|
|
" @mosaic help - Show this help",
|
|
].join("\n");
|
|
}
|
|
}
|