diff --git a/src/github/validation/trigger.ts b/src/github/validation/trigger.ts index 74b385d..4c6a1e3 100644 --- a/src/github/validation/trigger.ts +++ b/src/github/validation/trigger.ts @@ -10,6 +10,11 @@ import { isPullRequestReviewCommentEvent, } from "../context"; import type { ParsedGitHubContext } from "../context"; +import { + detectActionableSuggestion, + isCommentActionableForAutofix, + type ActionableSuggestionResult, +} from "../../utils/detect-actionable-suggestion"; export function checkContainsTrigger(context: ParsedGitHubContext): boolean { const { @@ -146,3 +151,89 @@ export async function checkTriggerAction(context: ParsedGitHubContext) { core.setOutput("contains_trigger", containsTrigger.toString()); return containsTrigger; } + +/** + * Checks if the context contains an actionable suggestion that can be automatically fixed. + * This is useful for autofix workflows that want to respond to code review suggestions, + * even when they come from bot accounts like claude[bot]. + * + * @param context - The parsed GitHub context + * @returns Detection result with confidence level and reason + */ +export function checkContainsActionableSuggestion( + context: ParsedGitHubContext, +): ActionableSuggestionResult { + // Extract comment body based on event type + let commentBody: string | undefined; + + if (isPullRequestReviewCommentEvent(context)) { + commentBody = context.payload.comment.body; + } else if (isIssueCommentEvent(context)) { + commentBody = context.payload.comment.body; + } else if (isPullRequestReviewEvent(context)) { + commentBody = context.payload.review.body ?? undefined; + } + + return detectActionableSuggestion(commentBody); +} + +/** + * Enhanced trigger check that also considers actionable suggestions. + * This function first checks for the standard trigger phrase, and if not found, + * optionally checks for actionable suggestions when `checkSuggestions` is true. + * + * @param context - The parsed GitHub context + * @param checkSuggestions - Whether to also check for actionable suggestions (default: false) + * @returns Whether the action should be triggered + */ +export function checkContainsTriggerOrActionableSuggestion( + context: ParsedGitHubContext, + checkSuggestions: boolean = false, +): boolean { + // First, check for standard trigger + if (checkContainsTrigger(context)) { + return true; + } + + // If checkSuggestions is enabled, also check for actionable suggestions + if (checkSuggestions) { + const suggestionResult = checkContainsActionableSuggestion(context); + if (suggestionResult.isActionable) { + console.log( + `Comment contains actionable suggestion: ${suggestionResult.reason} (confidence: ${suggestionResult.confidence})`, + ); + return true; + } + } + + return false; +} + +/** + * Checks if a PR comment is actionable for autofix purposes. + * This is a convenience function for workflows that want to automatically + * apply suggestions from code review comments. + * + * @param context - The parsed GitHub context + * @returns Whether the comment should be treated as actionable for autofix + */ +export function checkIsActionableForAutofix( + context: ParsedGitHubContext, +): boolean { + // Only applicable to PR review comment events + if (!isPullRequestReviewCommentEvent(context)) { + return false; + } + + const commentBody = context.payload.comment.body; + const authorUsername = context.payload.comment.user?.login; + + return isCommentActionableForAutofix(commentBody, authorUsername); +} + +// Re-export the types and functions from the utility module for convenience +export { + detectActionableSuggestion, + isCommentActionableForAutofix, + type ActionableSuggestionResult, +} from "../../utils/detect-actionable-suggestion"; diff --git a/src/utils/detect-actionable-suggestion.ts b/src/utils/detect-actionable-suggestion.ts new file mode 100644 index 0000000..c0d327e --- /dev/null +++ b/src/utils/detect-actionable-suggestion.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env bun + +/** + * Detects if a PR comment contains actionable suggestions that can be automatically fixed. + * + * This module identifies: + * 1. GitHub inline committable suggestions (```suggestion blocks) + * 2. Clear bug fix suggestions with specific patterns + * 3. Code fix recommendations with explicit changes + */ + +/** + * Patterns that indicate a comment contains a GitHub inline committable suggestion. + * These are code blocks that GitHub renders with a "Commit suggestion" button. + */ +const COMMITTABLE_SUGGESTION_PATTERN = /```suggestion\b[\s\S]*?```/i; + +/** + * Patterns that indicate a clear, actionable bug fix suggestion. + * These phrases typically precede concrete fix recommendations. + */ +const BUG_FIX_PATTERNS = [ + // Direct fix suggestions + /\bshould\s+(?:be|use|return|change\s+to)\b/i, + /\bchange\s+(?:this\s+)?to\b/i, + /\breplace\s+(?:this\s+)?with\b/i, + /\buse\s+(?:this\s+)?instead\b/i, + /\binstead\s+of\s+.*?,?\s*use\b/i, + + // Bug identification with fix + /\b(?:bug|issue|error|problem):\s*.*(?:fix|change|update|replace)/i, + /\bfix(?:ed)?\s+by\s+(?:chang|replac|updat)/i, + /\bto\s+fix\s+(?:this|the)\b/i, + + // Explicit code changes + /\bthe\s+(?:correct|proper|right)\s+(?:code|syntax|value|approach)\s+(?:is|would\s+be)\b/i, + /\bshould\s+(?:read|look\s+like)\b/i, + + // Missing/wrong patterns + /\bmissing\s+(?:a\s+)?(?:semicolon|bracket|parenthesis|quote|import|return|await)\b/i, + /\bextra\s+(?:semicolon|bracket|parenthesis|quote)\b/i, + /\bwrong\s+(?:type|value|variable|import|parameter)\b/i, + /\btypo\s+(?:in|here)\b/i, +]; + +/** + * Patterns that suggest code alternatives (less strong than direct fixes but still actionable). + */ +const CODE_ALTERNATIVE_PATTERNS = [ + /```[\w]*\n[\s\S]+?\n```/, // Any code block (might contain the fix) + /\b(?:try|consider)\s+(?:using|changing|replacing)\b/i, + /\bhere'?s?\s+(?:the|a)\s+(?:fix|solution|correction)\b/i, + /\b(?:correct|fixed|updated)\s+(?:version|code|implementation)\b/i, +]; + +export interface ActionableSuggestionResult { + /** Whether the comment contains an actionable suggestion */ + isActionable: boolean; + /** Whether the comment contains a GitHub inline committable suggestion */ + hasCommittableSuggestion: boolean; + /** Whether the comment contains clear bug fix language */ + hasBugFixSuggestion: boolean; + /** Whether the comment contains code alternatives */ + hasCodeAlternative: boolean; + /** Confidence level: 'high', 'medium', or 'low' */ + confidence: "high" | "medium" | "low"; + /** Reason for the determination */ + reason: string; +} + +/** + * Detects if a comment contains actionable suggestions that can be automatically fixed. + * + * @param commentBody - The body of the PR comment to analyze + * @returns Object with detection results and confidence level + * + * @example + * ```ts + * const result = detectActionableSuggestion("```suggestion\nfixed code\n```"); + * // { isActionable: true, hasCommittableSuggestion: true, confidence: 'high', ... } + * + * const result2 = detectActionableSuggestion("You should use `const` instead of `let` here"); + * // { isActionable: true, hasBugFixSuggestion: true, confidence: 'medium', ... } + * ``` + */ +export function detectActionableSuggestion( + commentBody: string | undefined | null, +): ActionableSuggestionResult { + if (!commentBody) { + return { + isActionable: false, + hasCommittableSuggestion: false, + hasBugFixSuggestion: false, + hasCodeAlternative: false, + confidence: "low", + reason: "Empty or missing comment body", + }; + } + + // Check for GitHub inline committable suggestion (highest confidence) + const hasCommittableSuggestion = + COMMITTABLE_SUGGESTION_PATTERN.test(commentBody); + if (hasCommittableSuggestion) { + return { + isActionable: true, + hasCommittableSuggestion: true, + hasBugFixSuggestion: false, + hasCodeAlternative: false, + confidence: "high", + reason: "Contains GitHub inline committable suggestion (```suggestion)", + }; + } + + // Check for clear bug fix patterns (medium-high confidence) + const matchedBugFixPattern = BUG_FIX_PATTERNS.find((pattern) => + pattern.test(commentBody), + ); + if (matchedBugFixPattern) { + // Higher confidence if also contains a code block + const hasCodeBlock = CODE_ALTERNATIVE_PATTERNS[0].test(commentBody); + return { + isActionable: true, + hasCommittableSuggestion: false, + hasBugFixSuggestion: true, + hasCodeAlternative: hasCodeBlock, + confidence: hasCodeBlock ? "high" : "medium", + reason: hasCodeBlock + ? "Contains clear bug fix suggestion with code example" + : "Contains clear bug fix suggestion", + }; + } + + // Check for code alternatives (medium confidence) + const matchedAlternativePattern = CODE_ALTERNATIVE_PATTERNS.find((pattern) => + pattern.test(commentBody), + ); + if (matchedAlternativePattern) { + return { + isActionable: true, + hasCommittableSuggestion: false, + hasBugFixSuggestion: false, + hasCodeAlternative: true, + confidence: "medium", + reason: "Contains code alternative or fix suggestion", + }; + } + + return { + isActionable: false, + hasCommittableSuggestion: false, + hasBugFixSuggestion: false, + hasCodeAlternative: false, + confidence: "low", + reason: "No actionable suggestion patterns detected", + }; +} + +/** + * Checks if a comment should be treated as actionable for autofix purposes, + * even if it comes from a bot account like claude[bot]. + * + * This is particularly useful for workflows that want to automatically apply + * suggestions from code review comments. + * + * @param commentBody - The body of the PR comment + * @param authorUsername - The username of the comment author + * @returns Whether the comment should be treated as actionable + */ +export function isCommentActionableForAutofix( + commentBody: string | undefined | null, + authorUsername?: string, +): boolean { + const result = detectActionableSuggestion(commentBody); + + // If it's already clearly actionable (high confidence), return true + if (result.confidence === "high") { + return true; + } + + // For medium confidence, be more lenient + if (result.confidence === "medium" && result.isActionable) { + return true; + } + + return false; +} + +/** + * Extracts the suggested code from a GitHub inline committable suggestion block. + * + * @param commentBody - The body of the PR comment + * @returns The suggested code content, or null if no suggestion block found + * + * @example + * ```ts + * const code = extractSuggestionCode("```suggestion\nconst x = 1;\n```"); + * // "const x = 1;" + * ``` + */ +export function extractSuggestionCode( + commentBody: string | undefined | null, +): string | null { + if (!commentBody) { + return null; + } + + const match = commentBody.match(/```suggestion\b\n?([\s\S]*?)```/i); + if (match && match[1] !== undefined) { + return match[1].trim(); + } + + return null; +} diff --git a/test/detect-actionable-suggestion.test.ts b/test/detect-actionable-suggestion.test.ts new file mode 100644 index 0000000..6235bd2 --- /dev/null +++ b/test/detect-actionable-suggestion.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "bun:test"; +import { + detectActionableSuggestion, + isCommentActionableForAutofix, + extractSuggestionCode, +} from "../src/utils/detect-actionable-suggestion"; + +describe("detectActionableSuggestion", () => { + describe("GitHub inline committable suggestions", () => { + it("should detect suggestion blocks with high confidence", () => { + const comment = `Here's a fix: +\`\`\`suggestion +const x = 1; +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasCommittableSuggestion).toBe(true); + expect(result.confidence).toBe("high"); + expect(result.reason).toContain("committable suggestion"); + }); + + it("should detect suggestion blocks with multiple lines", () => { + const comment = `\`\`\`suggestion +function foo() { + return bar(); +} +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasCommittableSuggestion).toBe(true); + expect(result.confidence).toBe("high"); + }); + + it("should detect suggestion blocks case-insensitively", () => { + const comment = `\`\`\`SUGGESTION +const x = 1; +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasCommittableSuggestion).toBe(true); + }); + + it("should not confuse regular code blocks with suggestion blocks", () => { + const comment = `\`\`\`javascript +const x = 1; +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.hasCommittableSuggestion).toBe(false); + // But it should still detect the code alternative + expect(result.hasCodeAlternative).toBe(true); + }); + }); + + describe("bug fix suggestions", () => { + it('should detect "should be" patterns', () => { + const comment = "This should be `const` instead of `let`"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + expect(result.confidence).toBe("medium"); + }); + + it('should detect "change to" patterns', () => { + const comment = "Change this to use async/await"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "replace with" patterns', () => { + const comment = "Replace this with Array.from()"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "use instead" patterns', () => { + const comment = "Use this instead of the deprecated method"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "instead of X, use Y" patterns', () => { + const comment = "Instead of forEach, use map"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "to fix this" patterns', () => { + const comment = "To fix this, you need to add the await keyword"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "the correct code is" patterns', () => { + const comment = "The correct code would be: return null;"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "missing semicolon" patterns', () => { + const comment = "Missing a semicolon at the end"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "typo" patterns', () => { + const comment = "Typo here: teh should be the"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it('should detect "wrong type" patterns', () => { + const comment = "Wrong type here, should be string not number"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + }); + + it("should have high confidence when bug fix suggestion includes code block", () => { + const comment = `You should use const here: +\`\`\`javascript +const x = 1; +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasBugFixSuggestion).toBe(true); + expect(result.hasCodeAlternative).toBe(true); + expect(result.confidence).toBe("high"); + }); + }); + + describe("code alternatives", () => { + it('should detect "try using" patterns', () => { + const comment = "Try using Array.map() instead"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + }); + + it('should detect "here\'s the fix" patterns', () => { + const comment = "Here's the fix for this issue"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + }); + + it("should detect code blocks as potential alternatives", () => { + const comment = `Try this approach: +\`\`\` +const result = []; +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasCodeAlternative).toBe(true); + }); + }); + + describe("non-actionable comments", () => { + it("should not flag general questions", () => { + const comment = "Why is this returning undefined?"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(false); + expect(result.confidence).toBe("low"); + }); + + it("should not flag simple observations", () => { + const comment = "This looks interesting"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(false); + }); + + it("should not flag approval comments", () => { + const comment = "LGTM! :+1:"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(false); + }); + + it("should handle empty comments", () => { + const result = detectActionableSuggestion(""); + expect(result.isActionable).toBe(false); + expect(result.reason).toContain("Empty"); + }); + + it("should handle null comments", () => { + const result = detectActionableSuggestion(null); + expect(result.isActionable).toBe(false); + }); + + it("should handle undefined comments", () => { + const result = detectActionableSuggestion(undefined); + expect(result.isActionable).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should handle comments with both suggestion block and bug fix language", () => { + const comment = `This should be fixed. Here's the suggestion: +\`\`\`suggestion +const x = 1; +\`\`\``; + const result = detectActionableSuggestion(comment); + // Suggestion block takes precedence (high confidence) + expect(result.isActionable).toBe(true); + expect(result.hasCommittableSuggestion).toBe(true); + expect(result.confidence).toBe("high"); + }); + + it("should handle very long comments", () => { + const longContent = "a".repeat(10000); + const comment = `${longContent} +\`\`\`suggestion +const x = 1; +\`\`\``; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + expect(result.hasCommittableSuggestion).toBe(true); + }); + + it("should handle comments with special characters", () => { + const comment = + "You should be using `const` here! @#$%^&* Change this to `let`"; + const result = detectActionableSuggestion(comment); + expect(result.isActionable).toBe(true); + }); + }); +}); + +describe("isCommentActionableForAutofix", () => { + it("should return true for high confidence suggestions", () => { + const comment = `\`\`\`suggestion +const x = 1; +\`\`\``; + expect(isCommentActionableForAutofix(comment)).toBe(true); + }); + + it("should return true for medium confidence suggestions", () => { + const comment = "You should use const here instead of let"; + expect(isCommentActionableForAutofix(comment)).toBe(true); + }); + + it("should return false for non-actionable comments", () => { + const comment = "This looks fine to me"; + expect(isCommentActionableForAutofix(comment)).toBe(false); + }); + + it("should handle bot authors correctly", () => { + const comment = `\`\`\`suggestion +const x = 1; +\`\`\``; + // Should still return true even for bot authors + expect(isCommentActionableForAutofix(comment, "claude[bot]")).toBe(true); + }); + + it("should handle empty comments", () => { + expect(isCommentActionableForAutofix("")).toBe(false); + expect(isCommentActionableForAutofix(null)).toBe(false); + expect(isCommentActionableForAutofix(undefined)).toBe(false); + }); +}); + +describe("extractSuggestionCode", () => { + it("should extract code from suggestion block", () => { + const comment = `Here's a fix: +\`\`\`suggestion +const x = 1; +\`\`\``; + expect(extractSuggestionCode(comment)).toBe("const x = 1;"); + }); + + it("should extract multi-line code from suggestion block", () => { + const comment = `\`\`\`suggestion +function foo() { + return bar(); +} +\`\`\``; + expect(extractSuggestionCode(comment)).toBe( + "function foo() {\n return bar();\n}", + ); + }); + + it("should handle empty suggestion blocks", () => { + const comment = `\`\`\`suggestion +\`\`\``; + expect(extractSuggestionCode(comment)).toBe(""); + }); + + it("should return null for comments without suggestion blocks", () => { + const comment = "Just a regular comment"; + expect(extractSuggestionCode(comment)).toBe(null); + }); + + it("should return null for empty comments", () => { + expect(extractSuggestionCode("")).toBe(null); + expect(extractSuggestionCode(null)).toBe(null); + expect(extractSuggestionCode(undefined)).toBe(null); + }); + + it("should not extract from regular code blocks", () => { + const comment = `\`\`\`javascript +const x = 1; +\`\`\``; + expect(extractSuggestionCode(comment)).toBe(null); + }); +});