Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/github/command-suggest.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;
};

/** 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<number>(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), ""] : [];
}
59 changes: 51 additions & 8 deletions src/github/commands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -175,15 +182,31 @@ export type MaintainerQueueDigest = {
// supplied" (#1960). Every other action command (gate-override, pause, resolve) keeps the existing `reason` shape.
const ARGUMENT_ACTION_COMMANDS = new Set<GittensoryActionCommandName>(["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
// start with "@gittensory" — `@gittensory-bot`, `@gittensorybot`, `@gittensory2` — are not misread as a
// 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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 <question>` answers contribution-quality Q&A with source citations and freshness.",
"- `@gittensory preflight` summarizes public PR hygiene.",
Expand Down Expand Up @@ -1588,4 +1630,5 @@ export const githubCommandsInternals = {
snapshotFreshnessFromWarnings,
refreshSections,
askSections,
helpSections,
};
89 changes: 89 additions & 0 deletions test/unit/command-suggest.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
59 changes: 57 additions & 2 deletions test/unit/github-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
parseAgentCommandFeedbackContext,
parseGittensoryMentionCommand,
sanitizePublicComment,
suggestCommand,
githubCommandsInternals,
} from "../../src/github/commands";

Expand All @@ -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");
Expand All @@ -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 });
Expand Down Expand Up @@ -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", () => {
Expand Down