feat(#171): Implement chat command parsing

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>
This commit is contained in:
2026-02-01 21:32:53 -06:00
parent 4ac21d1a3a
commit e689a1379c
40 changed files with 1618 additions and 6 deletions

View File

@@ -0,0 +1,258 @@
/**
* 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");
}
}