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");
}
}

View File

@@ -0,0 +1,293 @@
/**
* Command Parser Tests
*/
import { Test, TestingModule } from "@nestjs/testing";
import { describe, it, expect, beforeEach } from "vitest";
import { CommandParserService } from "./command-parser.service";
import { CommandAction } from "./command.interface";
describe("CommandParserService", () => {
let service: CommandParserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CommandParserService],
}).compile();
service = module.get<CommandParserService>(CommandParserService);
});
describe("parseCommand", () => {
describe("fix command", () => {
it("should parse fix command with current repo issue (#42)", () => {
const result = service.parseCommand("@mosaic fix #42");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.FIX);
expect(result.command.issue).toEqual({
number: 42,
});
}
});
it("should parse fix command with cross-repo issue (owner/repo#42)", () => {
const result = service.parseCommand("@mosaic fix mosaic/stack#42");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.FIX);
expect(result.command.issue).toEqual({
number: 42,
owner: "mosaic",
repo: "stack",
});
}
});
it("should parse fix command with full URL", () => {
const result = service.parseCommand(
"@mosaic fix https://git.mosaicstack.dev/mosaic/stack/issues/42"
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.FIX);
expect(result.command.issue).toEqual({
number: 42,
owner: "mosaic",
repo: "stack",
url: "https://git.mosaicstack.dev/mosaic/stack/issues/42",
});
}
});
it("should return error when fix command has no issue reference", () => {
const result = service.parseCommand("@mosaic fix");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("issue reference");
expect(result.error.help).toBeDefined();
}
});
it("should return error when fix command has invalid issue reference", () => {
const result = service.parseCommand("@mosaic fix invalid");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("Invalid issue reference");
}
});
});
describe("status command", () => {
it("should parse status command with job ID", () => {
const result = service.parseCommand("@mosaic status job-123");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.STATUS);
expect(result.command.jobId).toBe("job-123");
}
});
it("should return error when status command has no job ID", () => {
const result = service.parseCommand("@mosaic status");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("job ID");
expect(result.error.help).toBeDefined();
}
});
});
describe("cancel command", () => {
it("should parse cancel command with job ID", () => {
const result = service.parseCommand("@mosaic cancel job-123");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.CANCEL);
expect(result.command.jobId).toBe("job-123");
}
});
it("should return error when cancel command has no job ID", () => {
const result = service.parseCommand("@mosaic cancel");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("job ID");
}
});
});
describe("retry command", () => {
it("should parse retry command with job ID", () => {
const result = service.parseCommand("@mosaic retry job-123");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.RETRY);
expect(result.command.jobId).toBe("job-123");
}
});
it("should return error when retry command has no job ID", () => {
const result = service.parseCommand("@mosaic retry");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("job ID");
}
});
});
describe("verbose command", () => {
it("should parse verbose command with job ID", () => {
const result = service.parseCommand("@mosaic verbose job-123");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.VERBOSE);
expect(result.command.jobId).toBe("job-123");
}
});
it("should return error when verbose command has no job ID", () => {
const result = service.parseCommand("@mosaic verbose");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("job ID");
}
});
});
describe("quiet command", () => {
it("should parse quiet command", () => {
const result = service.parseCommand("@mosaic quiet");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.QUIET);
}
});
});
describe("help command", () => {
it("should parse help command", () => {
const result = service.parseCommand("@mosaic help");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.HELP);
}
});
});
describe("edge cases", () => {
it("should handle extra whitespace", () => {
const result = service.parseCommand(" @mosaic fix #42 ");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.FIX);
expect(result.command.issue?.number).toBe(42);
}
});
it("should be case-insensitive for @mosaic mention", () => {
const result = service.parseCommand("@Mosaic fix #42");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.FIX);
}
});
it("should be case-insensitive for action", () => {
const result = service.parseCommand("@mosaic FIX #42");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.action).toBe(CommandAction.FIX);
}
});
it("should return error when message does not start with @mosaic", () => {
const result = service.parseCommand("fix #42");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("@mosaic");
}
});
it("should return error when no action is provided", () => {
const result = service.parseCommand("@mosaic ");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("action");
expect(result.error.help).toBeDefined();
}
});
it("should return error for unknown action", () => {
const result = service.parseCommand("@mosaic unknown");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.message).toContain("Unknown action");
expect(result.error.help).toBeDefined();
}
});
});
describe("issue reference parsing", () => {
it("should parse GitHub-style issue URLs", () => {
const result = service.parseCommand("@mosaic fix https://github.com/owner/repo/issues/42");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.issue).toEqual({
number: 42,
owner: "owner",
repo: "repo",
url: "https://github.com/owner/repo/issues/42",
});
}
});
it("should parse Gitea-style issue URLs", () => {
const result = service.parseCommand(
"@mosaic fix https://git.example.com/owner/repo/issues/42"
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.issue).toEqual({
number: 42,
owner: "owner",
repo: "repo",
url: "https://git.example.com/owner/repo/issues/42",
});
}
});
it("should handle issue references with leading zeros", () => {
const result = service.parseCommand("@mosaic fix #042");
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.issue?.number).toBe(42);
}
});
});
});
});

View File

@@ -0,0 +1,90 @@
/**
* Command Parser Interfaces
*
* Defines types for parsing chat commands across all platforms
*/
/**
* Issue reference types
*/
export interface IssueReference {
/**
* Issue number
*/
number: number;
/**
* Repository owner (optional for current repo)
*/
owner?: string;
/**
* Repository name (optional for current repo)
*/
repo?: string;
/**
* Full URL (if provided as URL)
*/
url?: string;
}
/**
* Supported command actions
*/
export enum CommandAction {
FIX = "fix",
STATUS = "status",
CANCEL = "cancel",
RETRY = "retry",
VERBOSE = "verbose",
QUIET = "quiet",
HELP = "help",
}
/**
* Parsed command result
*/
export interface ParsedCommand {
/**
* The action to perform
*/
action: CommandAction;
/**
* Issue reference (for fix command)
*/
issue?: IssueReference;
/**
* Job ID (for status, cancel, retry, verbose commands)
*/
jobId?: string;
/**
* Raw arguments
*/
rawArgs: string[];
}
/**
* Command parse error
*/
export interface CommandParseError {
/**
* Error message
*/
message: string;
/**
* Suggested help text
*/
help?: string;
}
/**
* Command parse result (success or error)
*/
export type CommandParseResult =
| { success: true; command: ParsedCommand }
| { success: false; error: CommandParseError };