diff --git a/src/github/command-suggest.ts b/src/github/command-suggest.ts new file mode 100644 index 0000000000..12d0047acf --- /dev/null +++ b/src/github/command-suggest.ts @@ -0,0 +1,76 @@ +/** Pure did-you-mean suggester for unrecognized @gittensory verbs (#2170). */ + +export type CommandSuggestCatalog = { + mentionCommands: readonly string[]; + actionCommands: readonly string[]; + actionAliases: Readonly>; +}; + +/** Max Levenshtein distance for a did-you-mean suggestion. */ +export const COMMAND_SUGGEST_MAX_DISTANCE = 2; + +export function levenshteinDistance(left: string, right: string): number { + if (left === right) return 0; + if (left.length === 0) return right.length; + if (right.length === 0) return left.length; + const rows = left.length + 1; + const cols = right.length + 1; + const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)); + for (let row = 0; row < rows; row++) matrix[row]![0] = row; + for (let col = 0; col < cols; col++) matrix[0]![col] = col; + for (let row = 1; row < rows; row++) { + for (let col = 1; col < cols; col++) { + const cost = left[row - 1] === right[col - 1] ? 0 : 1; + matrix[row]![col] = Math.min( + matrix[row - 1]![col]! + 1, + matrix[row]![col - 1]! + 1, + matrix[row - 1]![col - 1]! + cost, + ); + } + } + return matrix[left.length]![right.length]!; +} + +function commandSuggestTargets(catalog: CommandSuggestCatalog): string[] { + return [...catalog.mentionCommands, ...catalog.actionCommands, ...Object.keys(catalog.actionAliases)]; +} + +export function isKnownGittensoryCommandVerb(rawVerb: string, catalog: CommandSuggestCatalog): boolean { + const verb = rawVerb.trim().toLowerCase(); + if (!verb) return false; + const canonical = catalog.actionAliases[verb] ?? verb; + return ( + catalog.mentionCommands.includes(canonical) || + catalog.actionCommands.includes(canonical) + ); +} + +/** Return the closest catalog command within {@link COMMAND_SUGGEST_MAX_DISTANCE}, or null. */ +export function suggestCommand(rawVerb: string, catalog: CommandSuggestCatalog): string | null { + const verb = rawVerb.trim().toLowerCase(); + if (!verb || isKnownGittensoryCommandVerb(verb, catalog)) return null; + const targets = commandSuggestTargets(catalog); + let best: { name: string; distance: number } | null = null; + for (const name of targets) { + const distance = levenshteinDistance(verb, name); + if (best === null || distance < best.distance) { + best = { name, distance }; + } + } + if (!best || best.distance > COMMAND_SUGGEST_MAX_DISTANCE) return null; + return best.name; +} + +export function formatDidYouMeanLine(suggestion: string): string { + return `- Did you mean \`@gittensory ${suggestion}\`?`; +} + +/** Help-card prefix lines for an unrecognized verb, or empty when no close match exists. */ +export function buildDidYouMeanSections( + rawVerb: string | undefined, + suggest: (verb: string) => string | null, +): string[] { + if (!rawVerb) return []; + const suggestion = suggest(rawVerb); + return suggestion !== null ? [formatDidYouMeanLine(suggestion), ""] : []; +} diff --git a/src/github/commands.ts b/src/github/commands.ts index 3366951c69..6e510d8732 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -1,4 +1,9 @@ import { AGENT_COMMAND_COMMENT_MARKER } from "./comments"; +import { + buildDidYouMeanSections, + suggestCommand as suggestCommandFromCatalog, + type CommandSuggestCatalog, +} from "./command-suggest"; import { gittensoryFooter } from "./footer"; import type { AgentRunBundle } from "../services/agent-orchestrator"; import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; @@ -72,6 +77,8 @@ export type GittensoryMentionCommand = { question?: string | undefined; reason?: string | undefined; argument?: string | undefined; + /** Present when a non-empty verb was unrecognized and downgraded to `help` (#2170). */ + unknownVerb?: string | undefined; }; type PublicAnswerCard = { @@ -175,6 +182,19 @@ export type MaintainerQueueDigest = { // supplied" (#1960). Every other action command (gate-override, pause, resolve) keeps the existing `reason` shape. const ARGUMENT_ACTION_COMMANDS = new Set(["explain"]); +function commandSuggestCatalog(): CommandSuggestCatalog { + return { + mentionCommands: GITTENSORY_MENTION_COMMAND_CATALOG.map((command) => command.id), + actionCommands: GITTENSORY_ACTION_COMMANDS, + actionAliases: GITTENSORY_ACTION_COMMAND_ALIASES, + }; +} + +/** Pure did-you-mean suggester for unrecognized @gittensory verbs (#2170). */ +export function suggestCommand(rawVerb: string): string | null { + return suggestCommandFromCatalog(rawVerb, commandSuggestCatalog()); +} + export function parseGittensoryMentionCommand(body: string | null | undefined): GittensoryMentionCommand | null { if (!body) return null; // `(?![\w-])` requires the mention to end at a non-identifier char, so other usernames that merely @@ -182,8 +202,11 @@ export function parseGittensoryMentionCommand(body: string | null | undefined): // bare `@gittensory help` command. A space, end-of-string, or punctuation still matches. const match = body.match(/(?:^|\s)@gittensory(?![\w-])(?:\s+([a-z-]+))?([^\n\r]*)/i); if (!match) return null; - const rawVerb = (match[1]?.toLowerCase() || "help") as GittensoryMentionCommandName | GittensoryActionCommandName; - const requested = (GITTENSORY_ACTION_COMMAND_ALIASES[rawVerb] ?? rawVerb) as GittensoryMentionCommandName | GittensoryActionCommandName; + const rawVerbToken = match[1]?.toLowerCase(); + if (!rawVerbToken) { + return { name: "help", raw: match[0].trim() }; + } + const requested = (GITTENSORY_ACTION_COMMAND_ALIASES[rawVerbToken] ?? rawVerbToken) as GittensoryMentionCommandName | GittensoryActionCommandName; if (ACTION_COMMANDS.has(requested as GittensoryActionCommandName)) { // match[2] is captured by a `*`-quantified group outside any optional wrapper, so it always matches // (possibly empty) and is never actually undefined; the ?? below is a noUncheckedIndexedAccess guard only. @@ -195,9 +218,18 @@ export function parseGittensoryMentionCommand(body: string | null | undefined): ? { name, raw: match[0].trim(), argument: tail } : { name, raw: match[0].trim(), reason: tail }; } - const name = COMMANDS.has(requested as GittensoryMentionCommandName) ? (requested as GittensoryMentionCommandName) : "help"; - const question = name === "ask" ? (match[2] ?? "").trim() : undefined; - return { name, raw: match[0].trim(), question: question && question.length > 0 ? question : undefined }; + if (COMMANDS.has(requested as GittensoryMentionCommandName)) { + const name = requested as GittensoryMentionCommandName; + // match[2] is always defined for the same reason as the action-command path above. + /* v8 ignore next */ + const question = name === "ask" ? (match[2] ?? "").trim() : undefined; + return { + name, + raw: match[0].trim(), + question: question && question.length > 0 ? question : undefined, + }; + } + return { name: "help", raw: match[0].trim(), unknownVerb: rawVerbToken }; } export function isMaintainerAssociation(association: string | null | undefined): boolean { @@ -289,7 +321,14 @@ export function buildPublicAgentCommandComment(args: { // Action commands (e.g. gate-override) never reach this Q&A renderer — they are handled and short-circuited // earlier — so narrow the widened parse name back to a Q&A command name for the answer-card helpers. const commandName = args.command.name as GittensoryMentionCommandName; - const sections = commandSections(commandName, args.bundle, args.officialMiner, args.maintainerDigest, args.command.question); + const sections = commandSections( + commandName, + args.bundle, + args.officialMiner, + args.maintainerDigest, + args.command.question, + args.command.unknownVerb, + ); const card = buildPublicAnswerCard({ command: commandName, sections, @@ -624,10 +663,12 @@ function commandSections( officialMiner: GittensorContributorSnapshot | null | undefined, maintainerDigest: MaintainerQueueDigest | null | undefined, question?: string | undefined, + /** Only read when `command === "help"` (#2170 did-you-mean hint). */ + unknownVerb?: string | undefined, ): string[] { switch (command) { case "help": - return helpSections(); + return helpSections(unknownVerb); case "ask": return askSections(bundle, question); case "miner-context": @@ -659,10 +700,11 @@ function commandSections( } } -function helpSections(): string[] { +function helpSections(unknownVerb?: string | undefined): string[] { return [ "**Commands**", "", + ...buildDidYouMeanSections(unknownVerb, suggestCommand), "- `@gittensory help` shows this command list.", "- `@gittensory ask ` answers contribution-quality Q&A with source citations and freshness.", "- `@gittensory preflight` summarizes public PR hygiene.", @@ -1588,4 +1630,5 @@ export const githubCommandsInternals = { snapshotFreshnessFromWarnings, refreshSections, askSections, + helpSections, }; diff --git a/test/unit/command-suggest.test.ts b/test/unit/command-suggest.test.ts new file mode 100644 index 0000000000..f0bab7c543 --- /dev/null +++ b/test/unit/command-suggest.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + COMMAND_SUGGEST_MAX_DISTANCE, + buildDidYouMeanSections, + formatDidYouMeanLine, + isKnownGittensoryCommandVerb, + levenshteinDistance, + suggestCommand, + type CommandSuggestCatalog, +} from "../../src/github/command-suggest"; +import { suggestCommand as liveSuggestCommand } from "../../src/github/commands"; + +const catalog: CommandSuggestCatalog = { + mentionCommands: ["help", "ask", "preflight", "blockers"], + actionCommands: ["gate-override", "review", "pause"], + actionAliases: { "re-review": "review" }, +}; + +describe("levenshteinDistance", () => { + it("covers equal, empty, insert, delete, and substitute paths", () => { + expect(levenshteinDistance("help", "help")).toBe(0); + expect(levenshteinDistance("", "")).toBe(0); + expect(levenshteinDistance("", "help")).toBe(4); + expect(levenshteinDistance("help", "")).toBe(4); + expect(levenshteinDistance("kitten", "sitting")).toBe(3); + expect(levenshteinDistance("ab", "a")).toBe(1); + expect(levenshteinDistance("a", "ab")).toBe(1); + expect(levenshteinDistance("abc", "axc")).toBe(1); + }); +}); + +describe("isKnownGittensoryCommandVerb", () => { + it("recognizes mention commands, action commands, and aliases", () => { + expect(isKnownGittensoryCommandVerb("preflight", catalog)).toBe(true); + expect(isKnownGittensoryCommandVerb("review", catalog)).toBe(true); + expect(isKnownGittensoryCommandVerb("re-review", catalog)).toBe(true); + expect(isKnownGittensoryCommandVerb("reveiw", catalog)).toBe(false); + expect(isKnownGittensoryCommandVerb("", catalog)).toBe(false); + expect(isKnownGittensoryCommandVerb(" ", catalog)).toBe(false); + }); +}); + +describe("suggestCommand", () => { + it("suggests the nearest command within the distance threshold", () => { + expect(suggestCommand("prefliht", catalog)).toBe("preflight"); + expect(suggestCommand("reveiw", catalog)).toBe("review"); + expect(suggestCommand("gate-overrid", catalog)).toBe("gate-override"); + expect(COMMAND_SUGGEST_MAX_DISTANCE).toBe(2); + }); + + it("returns null for empty, known, and far-off verbs", () => { + expect(suggestCommand("", catalog)).toBeNull(); + expect(suggestCommand(" ", catalog)).toBeNull(); + expect(suggestCommand("help", catalog)).toBeNull(); + expect(suggestCommand("review", catalog)).toBeNull(); + expect(suggestCommand("re-review", catalog)).toBeNull(); + expect(suggestCommand("zzzz", catalog)).toBeNull(); + expect(suggestCommand("xyzzyqwerty", catalog)).toBeNull(); + }); + + it("keeps the closest catalog entry when multiple targets are within range", () => { + expect(suggestCommand("hel", catalog)).toBe("help"); + }); + + it("suggests against the live production command catalog", () => { + expect(liveSuggestCommand("reveiw")).toBe("review"); + expect(liveSuggestCommand("prefliht")).toBe("preflight"); + expect(liveSuggestCommand("queue-summry")).toBe("queue-summary"); + }); +}); + +describe("formatDidYouMeanLine", () => { + it("renders a public-safe markdown hint", () => { + expect(formatDidYouMeanLine("preflight")).toBe("- Did you mean `@gittensory preflight`?"); + }); +}); + +describe("buildDidYouMeanSections", () => { + const suggest = (verb: string) => suggestCommand(verb, catalog); + + it("renders a hint for close typos and empty arrays otherwise", () => { + expect(buildDidYouMeanSections("reveiw", suggest)).toEqual([ + "- Did you mean `@gittensory review`?", + "", + ]); + expect(buildDidYouMeanSections(undefined, suggest)).toEqual([]); + expect(buildDidYouMeanSections("zzzz", suggest)).toEqual([]); + }); +}); diff --git a/test/unit/github-commands.test.ts b/test/unit/github-commands.test.ts index aa1a6e436e..35f43ba54e 100644 --- a/test/unit/github-commands.test.ts +++ b/test/unit/github-commands.test.ts @@ -9,6 +9,7 @@ import { parseAgentCommandFeedbackContext, parseGittensoryMentionCommand, sanitizePublicComment, + suggestCommand, githubCommandsInternals, } from "../../src/github/commands"; @@ -21,6 +22,7 @@ describe("GitHub mention commands", () => { name: "ask", question: "what should I fix first?", }); + expect(parseGittensoryMentionCommand("@gittensory ask")).toMatchObject({ name: "ask", question: undefined }); expect(parseGittensoryMentionCommand("@gittensory preflight")?.name).toBe("preflight"); expect(parseGittensoryMentionCommand("please @gittensory duplicate-check now")?.name).toBe("duplicate-check"); expect(parseGittensoryMentionCommand("@gittensory reviewability")?.name).toBe("reviewability"); @@ -31,7 +33,7 @@ describe("GitHub mention commands", () => { expect(parseGittensoryMentionCommand("@gittensory review-now")?.name).toBe("review-now"); expect(parseGittensoryMentionCommand("@gittensory needs-author")?.name).toBe("needs-author"); expect(parseGittensoryMentionCommand("@gittensory duplicate-clusters")?.name).toBe("duplicate-clusters"); - expect(parseGittensoryMentionCommand("@gittensory unknown")?.name).toBe("help"); + expect(parseGittensoryMentionCommand("@gittensory unknown")).toMatchObject({ name: "help", unknownVerb: "unknown" }); // gate-override is an action command: it must be recognized (NOT downgraded to "help") and carry the // trailing free text as its reason. expect(parseGittensoryMentionCommand("@gittensory gate-override")).toMatchObject({ name: "gate-override", reason: undefined }); @@ -81,8 +83,61 @@ describe("GitHub mention commands", () => { expect(parseGittensoryMentionCommand("@gittensory explain finding-7")).toMatchObject({ name: "explain", argument: "finding-7" }); expect(parseGittensoryMentionCommand("@gittensory explain finding-7")).not.toHaveProperty("reason"); // An unknown verb still resolves to "help", and a bare mention still resolves to "help" (unchanged). - expect(parseGittensoryMentionCommand("@gittensory reveiw")).toMatchObject({ name: "help" }); + expect(parseGittensoryMentionCommand("@gittensory reveiw")).toMatchObject({ name: "help", unknownVerb: "reveiw" }); expect(parseGittensoryMentionCommand("@gittensory")).toMatchObject({ name: "help" }); + expect(parseGittensoryMentionCommand("@gittensory")?.unknownVerb).toBeUndefined(); + }); + + it("surfaces did-you-mean hints in the help card for close typos (#2170)", () => { + expect(suggestCommand("prefliht")).toBe("preflight"); + expect(suggestCommand("reveiw")).toBe("review"); + const typo = parseGittensoryMentionCommand("@gittensory prefliht")!; + const typoBody = buildPublicAgentCommandComment({ + command: typo, + repo: null, + issue: { number: 1, title: "t", state: "open" }, + pullRequest: null, + actorKind: "maintainer", + }); + expect(typoBody).toContain("Did you mean `@gittensory preflight`?"); + const far = parseGittensoryMentionCommand("@gittensory zzzz")!; + const farBody = buildPublicAgentCommandComment({ + command: far, + repo: null, + issue: { number: 1, title: "t", state: "open" }, + pullRequest: null, + actorKind: "maintainer", + }); + expect(farBody).not.toContain("Did you mean"); + const ok = parseGittensoryMentionCommand("@gittensory preflight")!; + const okBody = buildPublicAgentCommandComment({ + command: ok, + repo: null, + issue: { number: 1, title: "t", state: "open" }, + pullRequest: null, + actorKind: "maintainer", + }); + expect(okBody).not.toContain("Did you mean"); + const bareHelp = parseGittensoryMentionCommand("@gittensory")!; + const bareHelpBody = buildPublicAgentCommandComment({ + command: bareHelp, + repo: null, + issue: { number: 1, title: "t", state: "open" }, + pullRequest: null, + actorKind: "maintainer", + }); + expect(bareHelpBody).not.toContain("Did you mean"); + expect(bareHelpBody).toContain("`@gittensory help` shows this command list."); + }); + + it("helpSections renders did-you-mean only for close unknown verbs (#2170)", () => { + const typo = githubCommandsInternals.helpSections("reveiw"); + expect(typo.join("\n")).toContain("Did you mean `@gittensory review`?"); + const far = githubCommandsInternals.helpSections("zzzz"); + expect(far.join("\n")).not.toContain("Did you mean"); + const bare = githubCommandsInternals.helpSections(); + expect(bare.join("\n")).not.toContain("Did you mean"); + expect(bare.join("\n")).toContain("**Commands**"); }); it("isGittensoryActionCommand distinguishes action verbs from Q&A commands", () => {