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:
258
apps/api/src/bridge/parser/command-parser.service.ts
Normal file
258
apps/api/src/bridge/parser/command-parser.service.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user