From 3e180b0a0013a1ef7d4b073cf0d9e4cf9b6bd752 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Fri, 25 Apr 2025 19:29:54 +0200 Subject: [PATCH 1/9] feat: add line-specific comments functionality --- config.json | 2 +- .../global-comment-handler.ts | 56 +++++++++ src/comment-handlers/index.ts | 32 +++++ src/comment-handlers/line-comments-handler.ts | 105 ++++++++++++++++ src/index.ts | 75 +++++------ src/populate-template.ts | 15 ++- src/prompt-strategies/get-strategy.ts | 5 +- .../line-comments-strategy.ts | 116 ++++++++++++++++++ src/send-to-anthropic.ts | 7 +- templates/line-comments-prompt.hbs | 65 ++++++++++ 10 files changed, 423 insertions(+), 55 deletions(-) create mode 100644 src/comment-handlers/global-comment-handler.ts create mode 100644 src/comment-handlers/index.ts create mode 100644 src/comment-handlers/line-comments-handler.ts create mode 100644 src/prompt-strategies/line-comments-strategy.ts create mode 100644 templates/line-comments-prompt.hbs diff --git a/config.json b/config.json index 65d3c73..91fe94c 100644 --- a/config.json +++ b/config.json @@ -1 +1 @@ -{"promptStrategy":"modified-files"} \ No newline at end of file +{"promptStrategy":"line-comments"} diff --git a/src/comment-handlers/global-comment-handler.ts b/src/comment-handlers/global-comment-handler.ts new file mode 100644 index 0000000..4efee61 --- /dev/null +++ b/src/comment-handlers/global-comment-handler.ts @@ -0,0 +1,56 @@ +import { type Context } from 'probot' + +// Marker to identify our AI analysis comments +const COMMENT_MARKER = '' + +/** + * Find existing AI analysis comment by looking for the unique marker + */ +async function findExistingAnalysisComment(context: Context, prNumber: number) { + const repo = context.repo() + + // Get all comments on the PR + const { data: comments } = await context.octokit.issues.listComments({ + ...repo, + issue_number: prNumber + }) + + // Find the comment with our marker + return comments.find((comment) => comment.body.includes(COMMENT_MARKER)) +} + +/** + * Handles the creation or update of a global comment containing the analysis. + * This is the original behavior of the application. + */ +export async function globalCommentHandler( + context: Context, + prNumber: number, + analysis: string +) { + const repo = context.repo() + + // Format the analysis with our marker + const formattedAnalysis = `${COMMENT_MARKER}\n\n${analysis}` + + // Check if we already have an analysis comment + const existingComment = await findExistingAnalysisComment(context, prNumber) + + if (existingComment) { + // Update the existing comment + await context.octokit.issues.updateComment({ + ...repo, + comment_id: existingComment.id, + body: formattedAnalysis + }) + return `Updated existing analysis comment on PR #${prNumber}` + } else { + // Post a new comment + await context.octokit.issues.createComment({ + ...repo, + issue_number: prNumber, + body: formattedAnalysis + }) + return `Created new analysis comment on PR #${prNumber}` + } +} diff --git a/src/comment-handlers/index.ts b/src/comment-handlers/index.ts new file mode 100644 index 0000000..07d9afc --- /dev/null +++ b/src/comment-handlers/index.ts @@ -0,0 +1,32 @@ +import { type Context } from 'probot' +import { globalCommentHandler } from './global-comment-handler.ts' +import { lineCommentsHandler } from './line-comments-handler.ts' + +/** + * Callback type for comment handlers + */ +export type CommentHandler = ( + context: Context, + prNumber: number, + analysis: string +) => Promise + +/** + * Gets the appropriate comment handler based on the strategy name. + * This allows for different comment handling strategies based on the prompt strategy. + * + * @param strategyName - The name of the prompt strategy used + * @returns The appropriate comment handler function + */ +export function getCommentHandler(strategyName: string): CommentHandler { + switch (strategyName.toLowerCase()) { + case 'line-comments': + return lineCommentsHandler + case 'default': + case 'modified-files': + default: + return globalCommentHandler + } +} + +export { globalCommentHandler, lineCommentsHandler } diff --git a/src/comment-handlers/line-comments-handler.ts b/src/comment-handlers/line-comments-handler.ts new file mode 100644 index 0000000..be8aae0 --- /dev/null +++ b/src/comment-handlers/line-comments-handler.ts @@ -0,0 +1,105 @@ +import { type Context } from 'probot' +import { globalCommentHandler } from './global-comment-handler.ts' + +// Marker to identify our AI review comments +const REVIEW_MARKER = '' + +/** + * Find existing AI review by looking for the unique marker + */ +async function findExistingReview(context: Context, prNumber: number) { + const repo = context.repo() + + // Get all reviews on the PR + const { data: reviews } = await context.octokit.pulls.listReviews({ + ...repo, + pull_number: prNumber + }) + + // Find the review with our marker + return reviews.find( + (review) => review.body && review.body.includes(REVIEW_MARKER) + ) +} + +/** + * Handles the creation of a review with line-specific comments. + * This expects the analysis to be a JSON string with the following structure: + * { + * "summary": "Overall PR summary", + * "comments": [ + * { + * "path": "file/path.ts", + * "line": 42, + * "body": "Comment text", + * "suggestion": "Optional suggested code" + * } + * ] + * } + */ +export async function lineCommentsHandler( + context: Context, + prNumber: number, + analysis: string +) { + const repo = context.repo() + + try { + // Parse the JSON response + const parsedAnalysis = JSON.parse(analysis) + + // Format the summary with our marker + const formattedSummary = `${REVIEW_MARKER}\n\n${parsedAnalysis.summary}` + + // Prepare comments for the review + const reviewComments = parsedAnalysis.comments.map((comment) => { + let commentBody = comment.body + + // Add suggested code if available + if (comment.suggestion) { + commentBody += '\n\n```suggestion\n' + comment.suggestion + '\n```' + } + + return { + path: comment.path, + line: comment.line, + body: commentBody + } + }) + + // Check if we already have a review for this PR + const existingReview = await findExistingReview(context, prNumber) + + if (existingReview) { + // For now, we can't update existing review comments directly with the GitHub API + // So we'll create a new review and mention it's an update + await context.octokit.pulls.createReview({ + ...repo, + pull_number: prNumber, + event: 'COMMENT', + body: `${REVIEW_MARKER}\n\n**Updated Review:** ${parsedAnalysis.summary}`, + comments: reviewComments + }) + + return `Updated review with ${reviewComments.length} line comments on PR #${prNumber}` + } else { + // Create a new review with the summary and comments + await context.octokit.pulls.createReview({ + ...repo, + pull_number: prNumber, + event: 'COMMENT', + body: formattedSummary, + comments: reviewComments + }) + } + + return `Created review with ${reviewComments.length} line comments on PR #${prNumber}` + } catch (error) { + // In case of error, fall back to the global comment handler + console.error( + 'Error parsing or creating line comments, falling back to global comment:', + error + ) + return globalCommentHandler(context, prNumber, analysis) + } +} diff --git a/src/index.ts b/src/index.ts index 0b69def..23df6dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,19 @@ import { config } from 'dotenv' -import { Probot, type Context } from 'probot' +import * as fs from 'fs/promises' +import * as path from 'path' +import { Probot } from 'probot' +import { getCommentHandler } from './comment-handlers/index.ts' import { sendToAnthropic } from './send-to-anthropic.ts' // Load environment variables config() -// Marker to identify our AI analysis comments -const COMMENT_MARKER = '' - export default async (app: Probot, { getRouter }) => { app.log.info('Revu GitHub App started!') // Container health check route getRouter('/healthz').get('/', (req, res) => res.end('OK')) - /** - * Find existing AI analysis comment by looking for the unique marker - */ - async function findExistingAnalysisComment(context: Context, prNumber) { - const repo = context.repo() - - // Get all comments on the PR - const { data: comments } = await context.octokit.issues.listComments({ - ...repo, - issue_number: prNumber - }) - - // Find the comment with our marker - return comments.find((comment) => comment.body.includes(COMMENT_MARKER)) - } - // Listen for PR opens and updates app.on( ['pull_request.opened', 'pull_request.synchronize'], @@ -52,44 +36,43 @@ export default async (app: Probot, { getRouter }) => { }) .then((response) => response.data.token) + // Get the current strategy from configuration + const strategyName = await getStrategyNameFromConfig() + // Get the analysis from Anthropic const analysis = await sendToAnthropic({ repositoryUrl, branch, - token: installationAccessToken + token: installationAccessToken, + strategyName }) - // Format the analysis with our marker - const formattedAnalysis = `${COMMENT_MARKER}\n\n${analysis}` + // Get the appropriate comment handler based on the strategy + const commentHandler = getCommentHandler(strategyName) - // Check if we already have an analysis comment - const existingComment = await findExistingAnalysisComment( - context, - pr.number - ) - - if (existingComment) { - // Update the existing comment - await context.octokit.issues.updateComment({ - ...repo, - comment_id: existingComment.id, - body: formattedAnalysis - }) - app.log.info(`Updated existing analysis comment on PR #${pr.number}`) - } else { - // Post a new comment - await context.octokit.issues.createComment({ - ...repo, - issue_number: pr.number, - body: formattedAnalysis - }) - app.log.info(`Created new analysis comment on PR #${pr.number}`) - } + // Handle the analysis with the appropriate handler + const result = await commentHandler(context, pr.number, analysis) + app.log.info(result) app.log.info(`Successfully analyzed PR #${pr.number}`) } catch (error) { app.log.error(`Error processing PR #${pr.number}: ${error}`) } } ) + + /** + * Gets the strategy name from the configuration file + */ + async function getStrategyNameFromConfig() { + try { + const configPath = path.join(process.cwd(), 'config.json') + const configContent = await fs.readFile(configPath, 'utf-8') + const config = JSON.parse(configContent) + return config.promptStrategy || 'default' + } catch (error) { + console.error('Error reading configuration:', error) + return 'default' + } + } } diff --git a/src/populate-template.ts b/src/populate-template.ts index 7cabe63..a410cb4 100644 --- a/src/populate-template.ts +++ b/src/populate-template.ts @@ -1,10 +1,14 @@ -import { getStrategyFromConfig } from './prompt-strategies/index.ts' +import { + getStrategyByName, + getStrategyFromConfig +} from './prompt-strategies/index.ts' interface PopulateTemplateOptions { repositoryUrl: string branch: string templatePath?: string token?: string + strategyName?: string } /** @@ -29,10 +33,13 @@ export async function populateTemplate({ repositoryUrl, branch, templatePath, - token + token, + strategyName }: PopulateTemplateOptions): Promise { - // Get the appropriate strategy based on configuration - const strategy = await getStrategyFromConfig() + // Get the appropriate strategy based on provided strategy name or configuration + const strategy = strategyName + ? getStrategyByName(strategyName) + : await getStrategyFromConfig() // Use the strategy to generate the prompt return strategy(repositoryUrl, branch, templatePath, token) diff --git a/src/prompt-strategies/get-strategy.ts b/src/prompt-strategies/get-strategy.ts index b01cfaf..3a4c9ef 100644 --- a/src/prompt-strategies/get-strategy.ts +++ b/src/prompt-strategies/get-strategy.ts @@ -2,6 +2,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import { defaultPromptStrategy } from './default-strategy.ts' import { modifiedFilesPromptStrategy } from './modified-files-strategy.ts' +import { lineCommentsPromptStrategy } from './line-comments-strategy.ts' import type { PromptStrategy } from './prompt-strategy.ts' /** @@ -11,11 +12,11 @@ import type { PromptStrategy } from './prompt-strategy.ts' * @returns The strategy implementation function */ export function getStrategyByName(strategyName: string): PromptStrategy { - // Currently only the default strategy is implemented - // Additional strategies can be added here in the future switch (strategyName.toLowerCase()) { case 'modified-files': return modifiedFilesPromptStrategy + case 'line-comments': + return lineCommentsPromptStrategy case 'default': default: return defaultPromptStrategy diff --git a/src/prompt-strategies/line-comments-strategy.ts b/src/prompt-strategies/line-comments-strategy.ts new file mode 100644 index 0000000..3b6633d --- /dev/null +++ b/src/prompt-strategies/line-comments-strategy.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs/promises' +import Handlebars from 'handlebars' +import * as path from 'path' +import { extractDiffFromRepo } from '../extract-diff.ts' +import { cleanUpRepository, prepareRepository } from '../repo-utils.ts' +import type { PromptStrategy } from './prompt-strategy.ts' + +/** + * Line comments prompt generation strategy. + * Similar to the modified-files strategy but requests line-specific comments. + * Instructs Claude to respond with a structured JSON format that includes: + * - A summary of the PR + * - Individual comments for specific lines of code + * + * @param repositoryUrl - The URL of the GitHub repository + * @param branch - The branch to analyze + * @param templatePath - Optional path to a custom template file + * @param token - Optional GitHub access token for private repositories + * @returns A promise that resolves to the generated prompt string + */ +export const lineCommentsPromptStrategy: PromptStrategy = async ( + repositoryUrl: string, + branch: string, + templatePath?: string, + token?: string +): Promise => { + // Prepare the repository for extraction with authentication if needed + const repoPath = await prepareRepository( + repositoryUrl, + branch, + undefined, + token + ) + const diff = await extractDiffFromRepo({ + branch, + repoPath + }) + + // Extract modified file paths from the diff + const modifiedFiles = extractModifiedFilePaths(diff) + + // Get content of modified files - use repoPath where the files actually are + const modifiedFilesContent = await getFilesContent(modifiedFiles, repoPath) + + await cleanUpRepository(repoPath) + + // Read and compile the template + const defaultTemplatePath = path.join( + process.cwd(), + 'templates', + 'line-comments-prompt.hbs' + ) + const actualTemplatePath = templatePath || defaultTemplatePath + const templateContent = await fs.readFile(actualTemplatePath, 'utf-8') + const template = Handlebars.compile(templateContent) + + const repoName = repositoryUrl.split('/').pop()?.replace('.git', '') || '' + const absolutePath = path.join(process.cwd(), repoName) + + // Populate the template with the data + const result = template({ + absolute_code_path: absolutePath, + git_diff_branch: diff, + modified_files: modifiedFilesContent + }) + + return result +} + +/** + * Extracts modified file paths from the git diff. + * + * @param diff - Git diff output + * @returns Array of modified file paths + */ +function extractModifiedFilePaths(diff: string): string[] { + const modifiedFiles = new Set() + + // Regular expression to match file paths in diff + const filePathRegex = /^diff --git a\/(.*?) b\/(.*?)$/gm + let match + + while ((match = filePathRegex.exec(diff)) !== null) { + // Use the 'b' path (new file path) + modifiedFiles.add(match[2]) + } + + return Array.from(modifiedFiles) +} + +/** + * Gets content of modified files. + * + * @param filePaths - Array of file paths + * @param repoPath - Absolute path to the repository + * @returns Object mapping file paths to their content + */ +async function getFilesContent( + filePaths: string[], + repoPath: string +): Promise> { + const result: Record = {} + + for (const filePath of filePaths) { + try { + const fullPath = path.join(repoPath, filePath) + const content = await fs.readFile(fullPath, 'utf-8') + result[filePath] = content + } catch (error) { + console.error(`Error reading file ${filePath}:`, error) + result[filePath] = `Error reading file: ${error}` + } + } + + return result +} diff --git a/src/send-to-anthropic.ts b/src/send-to-anthropic.ts index 6d66afc..f74e875 100644 --- a/src/send-to-anthropic.ts +++ b/src/send-to-anthropic.ts @@ -9,6 +9,7 @@ interface SendToAnthropicOptions { repositoryUrl: string branch: string token?: string + strategyName?: string } /** @@ -30,7 +31,8 @@ interface SendToAnthropicOptions { export async function sendToAnthropic({ repositoryUrl, branch, - token + token, + strategyName }: SendToAnthropicOptions) { // Initialize Anthropic client const anthropic = new Anthropic({ @@ -41,7 +43,8 @@ export async function sendToAnthropic({ const prompt = await populateTemplate({ repositoryUrl, branch, - token + token, + strategyName }) console.log('PROMPT', prompt) diff --git a/templates/line-comments-prompt.hbs b/templates/line-comments-prompt.hbs new file mode 100644 index 0000000..9276791 --- /dev/null +++ b/templates/line-comments-prompt.hbs @@ -0,0 +1,65 @@ +You are Revu, an expert code review assistant powered by Anthropic's Claude. Carefully analyze this Pull Request and provide detailed feedback. + +## Context + +Absolute code path: {{absolute_code_path}} + +## Modified Files + +{{#each modified_files}} +### {{@key}} +``` +{{{this}}} +``` + +{{/each}} + +## Git Diff + +```diff +{{{git_diff_branch}}} +``` + +## Instructions + +1. Analyze the modified code and git differences to understand the changes made. +2. Provide thorough critique by identifying: + - Code quality issues (readability, maintainability) + - Potential bugs + - Performance issues + - Security concerns + - Design problems + - Opportunities for improvement + +3. IMPORTANT: You MUST respond in the following structured JSON format: + +```json +{ + "summary": "An overall summary of the PR, explaining the changes and providing a general assessment", + "comments": [ + { + "path": "path/to/file.ts", + "line": 42, + "body": "Detailed comment explaining the issue or suggestion", + "suggestion": "Suggested code to fix the issue (optional)" + }, + // Additional comments... + ] +} +``` + +Where: +- "summary": Provides an overview of the PR, its strengths and weaknesses +- "comments": An array of specific comments where: + - "path": The file path relative to the repository root + - "line": The line number where the comment applies + - "body": The detailed comment (be precise and constructive) + - "suggestion": A proposed improved code (optional) + +## Important Notes + +- Be precise about line numbers. Make sure they correspond to the actual lines in the files after modifications. +- Focus on significant issues rather than minor style or convention concerns. +- Provide clear, educational explanations for each comment, not just identifying the problem. +- Ensure your response is strictly valid JSON that can be directly parsed. +- Do not include the ```json tag at the beginning or ``` at the end in your response. From 39888e134fbc996db29cc26e6ff75c8a7a7d9a9d Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Fri, 25 Apr 2025 20:01:21 +0200 Subject: [PATCH 2/9] Update line comments handler with createReviewComment implementation --- src/comment-handlers/line-comments-handler.ts | 151 ++++++++++++------ 1 file changed, 106 insertions(+), 45 deletions(-) diff --git a/src/comment-handlers/line-comments-handler.ts b/src/comment-handlers/line-comments-handler.ts index be8aae0..f06f97c 100644 --- a/src/comment-handlers/line-comments-handler.ts +++ b/src/comment-handlers/line-comments-handler.ts @@ -1,29 +1,58 @@ import { type Context } from 'probot' import { globalCommentHandler } from './global-comment-handler.ts' -// Marker to identify our AI review comments -const REVIEW_MARKER = '' +// Marker for the global summary comment +const SUMMARY_MARKER = '' + +// Marker pattern for individual comments +// Each comment gets a unique ID based on file path and line number +const COMMENT_MARKER_PREFIX = '' + +/** + * Creates a unique marker ID for a specific comment + */ +function createCommentMarkerId(path: string, line: number): string { + // Create a deterministic ID based on file path and line number + return `${path}:${line}`.replace(/[^a-zA-Z0-9-_:.]/g, '_') +} /** - * Find existing AI review by looking for the unique marker + * Finds all existing review comments on a PR that have our marker */ -async function findExistingReview(context: Context, prNumber: number) { +async function findExistingComments(context: Context, prNumber: number) { const repo = context.repo() - // Get all reviews on the PR - const { data: reviews } = await context.octokit.pulls.listReviews({ + // Get all review comments on the PR + const { data: comments } = await context.octokit.pulls.listReviewComments({ ...repo, pull_number: prNumber }) - // Find the review with our marker - return reviews.find( - (review) => review.body && review.body.includes(REVIEW_MARKER) + // Filter to comments with our marker + return comments.filter((comment) => + comment.body.includes(COMMENT_MARKER_PREFIX) ) } /** - * Handles the creation of a review with line-specific comments. + * Find the existing summary comment + */ +async function findExistingSummaryComment(context: Context, prNumber: number) { + const repo = context.repo() + + // Get all comments on the PR + const { data: comments } = await context.octokit.issues.listComments({ + ...repo, + issue_number: prNumber + }) + + // Find the comment with our marker + return comments.find((comment) => comment.body.includes(SUMMARY_MARKER)) +} + +/** + * Handles the creation of individual review comments on specific lines. * This expects the analysis to be a JSON string with the following structure: * { * "summary": "Overall PR summary", @@ -49,51 +78,83 @@ export async function lineCommentsHandler( const parsedAnalysis = JSON.parse(analysis) // Format the summary with our marker - const formattedSummary = `${REVIEW_MARKER}\n\n${parsedAnalysis.summary}` + const formattedSummary = `${SUMMARY_MARKER}\n\n${parsedAnalysis.summary}` + + // Handle the summary comment (global PR comment) + const existingSummary = await findExistingSummaryComment(context, prNumber) + if (existingSummary) { + // Update existing summary + await context.octokit.issues.updateComment({ + ...repo, + comment_id: existingSummary.id, + body: formattedSummary + }) + } else { + // Create new summary + await context.octokit.issues.createComment({ + ...repo, + issue_number: prNumber, + body: formattedSummary + }) + } + + // Get the commit SHA for the PR head + const { data: pullRequest } = await context.octokit.pulls.get({ + ...repo, + pull_number: prNumber + }) + const commitSha = pullRequest.head.sha - // Prepare comments for the review - const reviewComments = parsedAnalysis.comments.map((comment) => { - let commentBody = comment.body + // Get existing review comments + const existingComments = await findExistingComments(context, prNumber) + + // Track created/updated comments + let createdCount = 0 + let updatedCount = 0 + + // Process each comment + for (const comment of parsedAnalysis.comments) { + // Generate marker ID for this comment + const markerId = createCommentMarkerId(comment.path, comment.line) + + // Format the comment body with marker + let commentBody = `${COMMENT_MARKER_PREFIX}${markerId}${COMMENT_MARKER_SUFFIX}\n\n${comment.body}` // Add suggested code if available if (comment.suggestion) { commentBody += '\n\n```suggestion\n' + comment.suggestion + '\n```' } - return { - path: comment.path, - line: comment.line, - body: commentBody - } - }) - - // Check if we already have a review for this PR - const existingReview = await findExistingReview(context, prNumber) - - if (existingReview) { - // For now, we can't update existing review comments directly with the GitHub API - // So we'll create a new review and mention it's an update - await context.octokit.pulls.createReview({ - ...repo, - pull_number: prNumber, - event: 'COMMENT', - body: `${REVIEW_MARKER}\n\n**Updated Review:** ${parsedAnalysis.summary}`, - comments: reviewComments - }) + // Check if this comment already exists + const existingComment = existingComments.find( + (existing) => + existing.body.includes(`${COMMENT_MARKER_PREFIX}${markerId}`) && + existing.path === comment.path + ) - return `Updated review with ${reviewComments.length} line comments on PR #${prNumber}` - } else { - // Create a new review with the summary and comments - await context.octokit.pulls.createReview({ - ...repo, - pull_number: prNumber, - event: 'COMMENT', - body: formattedSummary, - comments: reviewComments - }) + if (existingComment) { + // Update existing comment + await context.octokit.pulls.updateReviewComment({ + ...repo, + comment_id: existingComment.id, + body: commentBody + }) + updatedCount++ + } else { + // Create new comment + await context.octokit.pulls.createReviewComment({ + ...repo, + pull_number: prNumber, + commit_id: commitSha, + path: comment.path, + line: comment.line, + body: commentBody + }) + createdCount++ + } } - return `Created review with ${reviewComments.length} line comments on PR #${prNumber}` + return `PR #${prNumber}: Created ${createdCount} and updated ${updatedCount} line comments` } catch (error) { // In case of error, fall back to the global comment handler console.error( From b55c00e641fab2b131e430dba43fb16a7dd83c00 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Fri, 25 Apr 2025 20:23:01 +0200 Subject: [PATCH 3/9] feat: ajouter architecture modulaire pour les senders Anthropic avec support des tools --- src/anthropic-senders/default-sender.ts | 35 ++++++ src/anthropic-senders/index.ts | 27 ++++ src/anthropic-senders/line-comments-sender.ts | 118 ++++++++++++++++++ src/send-to-anthropic.ts | 38 ++---- 4 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 src/anthropic-senders/default-sender.ts create mode 100644 src/anthropic-senders/index.ts create mode 100644 src/anthropic-senders/line-comments-sender.ts diff --git a/src/anthropic-senders/default-sender.ts b/src/anthropic-senders/default-sender.ts new file mode 100644 index 0000000..6e418e9 --- /dev/null +++ b/src/anthropic-senders/default-sender.ts @@ -0,0 +1,35 @@ +import Anthropic from '@anthropic-ai/sdk' + +/** + * Default Anthropic sender. + * This sender is used by default and sends the prompt to Anthropic + * expecting a regular text response. + * + * @param prompt - The prompt to send to Anthropic + * @returns The text response from Anthropic + */ +export async function defaultSender(prompt: string): Promise { + // Initialize Anthropic client + const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY + }) + + // Send to Anthropic API + const message = await anthropic.messages.create({ + model: 'claude-3-7-sonnet-latest', + max_tokens: 4096, + temperature: 0, // Using 0 for consistent, deterministic code review feedback + messages: [ + { + role: 'user', + content: prompt + } + ] + }) + + // Extract text from the content block + if (message.content[0].type !== 'text') { + throw new Error('Unexpected response type from Anthropic') + } + return message.content[0].text +} diff --git a/src/anthropic-senders/index.ts b/src/anthropic-senders/index.ts new file mode 100644 index 0000000..5078b76 --- /dev/null +++ b/src/anthropic-senders/index.ts @@ -0,0 +1,27 @@ +import { defaultSender } from './default-sender.ts' +import { lineCommentsSender } from './line-comments-sender.ts' + +/** + * Type definition for all Anthropic senders + */ +export type AnthropicSender = (prompt: string) => Promise + +/** + * Gets the appropriate sender based on the strategy name. + * This selects how to send and process the response to/from Anthropic API. + * + * @param strategyName - The name of the prompt strategy used + * @returns The appropriate sender function + */ +export function getSender(strategyName?: string): AnthropicSender { + switch (strategyName?.toLowerCase()) { + case 'line-comments': + return lineCommentsSender + case 'default': + case 'modified-files': + default: + return defaultSender + } +} + +export { defaultSender, lineCommentsSender } diff --git a/src/anthropic-senders/line-comments-sender.ts b/src/anthropic-senders/line-comments-sender.ts new file mode 100644 index 0000000..a142294 --- /dev/null +++ b/src/anthropic-senders/line-comments-sender.ts @@ -0,0 +1,118 @@ +import Anthropic from '@anthropic-ai/sdk' + +// Type for code review response +interface CodeReviewResponse { + summary: string + comments: Array<{ + path: string + line: number + body: string + suggestion?: string + }> +} + +/** + * Line comments Anthropic sender. + * This sender uses Anthropic's Tool Use / Function Calling capability + * to enforce a structured JSON response with specific line-based comments. + * + * @param prompt - The prompt to send to Anthropic + * @returns A stringified JSON response containing structured review comments + */ +export async function lineCommentsSender(prompt: string): Promise { + // Initialize Anthropic client + const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY + }) + + // Send to Anthropic API with tool use configuration + const message = await anthropic.messages.create({ + model: 'claude-3-7-sonnet-latest', + max_tokens: 4096, + temperature: 0, + messages: [ + { + role: 'user', + content: prompt + } + ], + tools: [ + { + name: 'provide_code_review', + description: + 'Provide structured code review with line-specific comments', + input_schema: { + type: 'object', + properties: { + summary: { + type: 'string', + description: 'Overall summary of the PR' + }, + comments: { + type: 'array', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'File path relative to repository root' + }, + line: { + type: 'integer', + description: 'Line number for the comment' + }, + body: { + type: 'string', + description: 'Detailed comment about the issue' + }, + suggestion: { + type: 'string', + description: 'Suggested code to fix the issue (optional)' + } + }, + required: ['path', 'line', 'body'] + } + } + }, + required: ['summary', 'comments'] + } + } + ] + }) + + // Extract response from tool use + // Find content blocks that are tool_use type + for (const content of message.content) { + if (content.type === 'tool_use') { + if (content.name === 'provide_code_review' && content.input) { + // Return the structured response as a JSON string + return JSON.stringify(content.input as CodeReviewResponse) + } + } + } + + // Fallback if tool use failed or returned unexpected format + for (const content of message.content) { + if (content.type === 'text') { + // Try to parse any JSON that might be in the response + try { + const text = content.text + const jsonMatch = text.match(/```json\s*([\s\S]*?)\s*```/) + if (jsonMatch && jsonMatch[1]) { + return jsonMatch[1].trim() + } + // If the whole response is potentially JSON + if (text.trim().startsWith('{') && text.trim().endsWith('}')) { + return text + } + + // Just return the text as is + return text + } catch { + // Silent catch - continue to next content block or error + } + } + } + + throw new Error('Unexpected response format from Anthropic') +} diff --git a/src/send-to-anthropic.ts b/src/send-to-anthropic.ts index f74e875..86abe42 100644 --- a/src/send-to-anthropic.ts +++ b/src/send-to-anthropic.ts @@ -1,6 +1,6 @@ -import Anthropic from '@anthropic-ai/sdk' import * as dotenv from 'dotenv' import { populateTemplate } from './populate-template.ts' +import { getSender } from './anthropic-senders/index.ts' // Load environment variables dotenv.config() @@ -15,15 +15,16 @@ interface SendToAnthropicOptions { /** * Sends repository data to Anthropic's API for analysis. * This function: - * 1. Initializes the Anthropic client with API key from environment - * 2. Gets populated template with repository data - * 3. Sends the data to Anthropic's API for analysis - * 4. Processes and returns the analysis response + * 1. Gets populated template with repository data + * 2. Selects the appropriate sender based on the strategy + * 3. Sends the data to Anthropic's API for analysis with the selected sender + * 4. Returns the analysis response * * @param {Object} options - The options for Anthropic analysis * @param {string} options.repositoryUrl - The URL of the GitHub repository * @param {string} options.branch - The branch to analyze * @param {string} [options.token] - Optional GitHub access token for private repositories + * @param {string} [options.strategyName] - Optional strategy name to use * @returns {Promise} The analysis response from Anthropic * @throws {Error} If API communication fails or response is unexpected * @requires ANTHROPIC_API_KEY environment variable to be set @@ -34,11 +35,6 @@ export async function sendToAnthropic({ token, strategyName }: SendToAnthropicOptions) { - // Initialize Anthropic client - const anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY - }) - // Get the populated template const prompt = await populateTemplate({ repositoryUrl, @@ -50,23 +46,11 @@ export async function sendToAnthropic({ console.log('PROMPT', prompt) console.log('repositoryUrl', repositoryUrl) console.log('branch', branch) + console.log('strategy', strategyName || 'default') - // Send to Anthropic API - const message = await anthropic.messages.create({ - model: 'claude-3-7-sonnet-latest', - max_tokens: 4096, - temperature: 0, // Using 0 for consistent, deterministic code review feedback - messages: [ - { - role: 'user', - content: prompt - } - ] - }) + // Get the appropriate sender based on the strategy + const sender = getSender(strategyName) - // Extract text from the content block - if (message.content[0].type !== 'text') { - throw new Error('Unexpected response type from Anthropic') - } - return message.content[0].text + // Send to Anthropic API using the selected sender + return sender(prompt) } From 23b991bee6fb9a487170d3c07b6d2e3bdc364c75 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Sat, 26 Apr 2025 00:45:20 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20am=C3=A9liorer=20le=20prompt=20des?= =?UTF-8?q?=20commentaires=20par=20ligne=20pour=20encourager=20l'utilisati?= =?UTF-8?q?on=20du=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/line-comments-prompt.hbs | 34 +++++++++--------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/templates/line-comments-prompt.hbs b/templates/line-comments-prompt.hbs index 9276791..63f1e2b 100644 --- a/templates/line-comments-prompt.hbs +++ b/templates/line-comments-prompt.hbs @@ -31,35 +31,21 @@ Absolute code path: {{absolute_code_path}} - Design problems - Opportunities for improvement -3. IMPORTANT: You MUST respond in the following structured JSON format: +3. IMPORTANT: Your review should consist of MULTIPLE LINE-SPECIFIC COMMENTS rather than a single global comment. Each issue or suggestion should be tied to a specific line of code where the problem exists. -```json -{ - "summary": "An overall summary of the PR, explaining the changes and providing a general assessment", - "comments": [ - { - "path": "path/to/file.ts", - "line": 42, - "body": "Detailed comment explaining the issue or suggestion", - "suggestion": "Suggested code to fix the issue (optional)" - }, - // Additional comments... - ] -} -``` +4. You MUST use the provided "provide_code_review" tool to format your response. This is critical because: + - It allows us to place each of your comments at the exact relevant line in the code + - It enables developers to see comments in context, directly in their editor + - It facilitates addressing each issue individually during the review process -Where: -- "summary": Provides an overview of the PR, its strengths and weaknesses -- "comments": An array of specific comments where: - - "path": The file path relative to the repository root - - "line": The line number where the comment applies - - "body": The detailed comment (be precise and constructive) - - "suggestion": A proposed improved code (optional) +5. When using the "provide_code_review" tool, make sure to provide: + - A comprehensive overall summary of the PR in the "summary" field + - Multiple specific comments targeting different issues with accurate file paths and line numbers + - Detailed explanations in each comment's body + - Code suggestions where appropriate ## Important Notes - Be precise about line numbers. Make sure they correspond to the actual lines in the files after modifications. - Focus on significant issues rather than minor style or convention concerns. - Provide clear, educational explanations for each comment, not just identifying the problem. -- Ensure your response is strictly valid JSON that can be directly parsed. -- Do not include the ```json tag at the beginning or ``` at the end in your response. From 81c2017118d75232d15d586a6900332b08c99a73 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Sat, 26 Apr 2025 01:23:31 +0200 Subject: [PATCH 5/9] feat: ajouter validation Zod pour le format JSON des commentaires --- package.json | 3 +- src/comment-handlers/line-comments-handler.ts | 32 ++++++++++++++++++- yarn.lock | 5 +++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f5a2588..7743dbb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "@anthropic-ai/sdk": "^0.36.3", "dotenv": "^16.4.7", "handlebars": "^4.7.8", - "probot": "^13.4.3" + "probot": "^13.4.3", + "zod": "^3.24.3" }, "devDependencies": { "@types/node": "22.13.1", diff --git a/src/comment-handlers/line-comments-handler.ts b/src/comment-handlers/line-comments-handler.ts index f06f97c..2059330 100644 --- a/src/comment-handlers/line-comments-handler.ts +++ b/src/comment-handlers/line-comments-handler.ts @@ -1,4 +1,5 @@ import { type Context } from 'probot' +import { z } from 'zod' import { globalCommentHandler } from './global-comment-handler.ts' // Marker for the global summary comment @@ -51,6 +52,19 @@ async function findExistingSummaryComment(context: Context, prNumber: number) { return comments.find((comment) => comment.body.includes(SUMMARY_MARKER)) } +// Schémas de validation pour garantir le bon format de la réponse +const CommentSchema = z.object({ + path: z.string(), + line: z.number().int().positive(), + body: z.string(), + suggestion: z.string().optional() +}) + +const AnalysisSchema = z.object({ + summary: z.string(), + comments: z.array(CommentSchema) +}) + /** * Handles the creation of individual review comments on specific lines. * This expects the analysis to be a JSON string with the following structure: @@ -75,7 +89,23 @@ export async function lineCommentsHandler( try { // Parse the JSON response - const parsedAnalysis = JSON.parse(analysis) + const rawParsedAnalysis = JSON.parse(analysis) + + // Valider la structure avec Zod + const validationResult = AnalysisSchema.safeParse(rawParsedAnalysis) + + if (!validationResult.success) { + console.error( + "Validation de l'analyse échouée:", + validationResult.error.format() + ) + throw new Error( + "Format d'analyse invalide: " + validationResult.error.message + ) + } + + // Utiliser le résultat validé et typé + const parsedAnalysis = validationResult.data // Format the summary with our marker const formattedSummary = `${SUMMARY_MARKER}\n\n${parsedAnalysis.summary}` diff --git a/yarn.lock b/yarn.lock index 40c773c..406974e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3824,3 +3824,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.24.3: + version "3.24.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.3.tgz#1f40f750a05e477396da64438e0e1c0995dafd87" + integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== From a8b94a7754a6a65d48150d1e82d5e21d405b81c0 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Tue, 6 May 2025 18:20:27 +0200 Subject: [PATCH 6/9] log error on tool call failure --- src/anthropic-senders/line-comments-sender.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/anthropic-senders/line-comments-sender.ts b/src/anthropic-senders/line-comments-sender.ts index a142294..7e8eb09 100644 --- a/src/anthropic-senders/line-comments-sender.ts +++ b/src/anthropic-senders/line-comments-sender.ts @@ -87,29 +87,31 @@ export async function lineCommentsSender(prompt: string): Promise { if (content.name === 'provide_code_review' && content.input) { // Return the structured response as a JSON string return JSON.stringify(content.input as CodeReviewResponse) + } else { + console.log('Input:', content.input) + console.log('Tool name:', content.name) + throw new Error('Tool name or input incorect') } - } - } + } else { + // Fallback if tool use failed or returned unexpected format + if (content.type === 'text') { + // Try to parse any JSON that might be in the response + try { + const text = content.text + const jsonMatch = text.match(/```json\s*([\s\S]*?)\s*```/) + if (jsonMatch && jsonMatch[1]) { + return jsonMatch[1].trim() + } + // If the whole response is potentially JSON + if (text.trim().startsWith('{') && text.trim().endsWith('}')) { + return text + } - // Fallback if tool use failed or returned unexpected format - for (const content of message.content) { - if (content.type === 'text') { - // Try to parse any JSON that might be in the response - try { - const text = content.text - const jsonMatch = text.match(/```json\s*([\s\S]*?)\s*```/) - if (jsonMatch && jsonMatch[1]) { - return jsonMatch[1].trim() - } - // If the whole response is potentially JSON - if (text.trim().startsWith('{') && text.trim().endsWith('}')) { + // Just return the text as is return text + } catch { + // Silent catch - continue to next content block or error } - - // Just return the text as is - return text - } catch { - // Silent catch - continue to next content block or error } } } From c417dbea7e00d3b530248321a390ae00b873b5b2 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Tue, 6 May 2025 18:38:25 +0200 Subject: [PATCH 7/9] add a blank before colon --- src/comment-handlers/line-comments-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/comment-handlers/line-comments-handler.ts b/src/comment-handlers/line-comments-handler.ts index 2059330..7b19c6d 100644 --- a/src/comment-handlers/line-comments-handler.ts +++ b/src/comment-handlers/line-comments-handler.ts @@ -96,11 +96,11 @@ export async function lineCommentsHandler( if (!validationResult.success) { console.error( - "Validation de l'analyse échouée:", + "Validation de l'analyse échouée :", validationResult.error.format() ) throw new Error( - "Format d'analyse invalide: " + validationResult.error.message + "Format d'analyse invalide : " + validationResult.error.message ) } From f964b31170bbc14a8b7d9f2e326a09dc93555e8e Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Wed, 7 May 2025 17:21:46 +0200 Subject: [PATCH 8/9] rename token to githubAccessToken --- src/prompt-strategies/line-comments-strategy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/prompt-strategies/line-comments-strategy.ts b/src/prompt-strategies/line-comments-strategy.ts index 3b6633d..f0751a0 100644 --- a/src/prompt-strategies/line-comments-strategy.ts +++ b/src/prompt-strategies/line-comments-strategy.ts @@ -15,21 +15,21 @@ import type { PromptStrategy } from './prompt-strategy.ts' * @param repositoryUrl - The URL of the GitHub repository * @param branch - The branch to analyze * @param templatePath - Optional path to a custom template file - * @param token - Optional GitHub access token for private repositories + * @param githubAccessToken - Optional GitHub access token for private repositories * @returns A promise that resolves to the generated prompt string */ export const lineCommentsPromptStrategy: PromptStrategy = async ( repositoryUrl: string, branch: string, templatePath?: string, - token?: string + githubAccessToken?: string ): Promise => { // Prepare the repository for extraction with authentication if needed const repoPath = await prepareRepository( repositoryUrl, branch, undefined, - token + githubAccessToken ) const diff = await extractDiffFromRepo({ branch, From 226617bd3d3efc28d750d16475d75e1fd67463f9 Mon Sep 17 00:00:00 2001 From: Gary van Woerkens Date: Wed, 7 May 2025 18:59:24 +0200 Subject: [PATCH 9/9] use upsertComment method --- .../global-comment-handler.ts | 21 +++++++++++++++--- src/comment-handlers/line-comments-handler.ts | 22 +++++-------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/comment-handlers/global-comment-handler.ts b/src/comment-handlers/global-comment-handler.ts index 4efee61..ec814d7 100644 --- a/src/comment-handlers/global-comment-handler.ts +++ b/src/comment-handlers/global-comment-handler.ts @@ -1,4 +1,10 @@ -import { type Context } from 'probot' +import { type Context, ProbotOctokit } from 'probot' + +type ListCommentsResponse = Awaited< + ReturnType +> + +type SingleComment = ListCommentsResponse['data'][number] // Marker to identify our AI analysis comments const COMMENT_MARKER = '' @@ -28,14 +34,23 @@ export async function globalCommentHandler( prNumber: number, analysis: string ) { - const repo = context.repo() - // Format the analysis with our marker const formattedAnalysis = `${COMMENT_MARKER}\n\n${analysis}` // Check if we already have an analysis comment const existingComment = await findExistingAnalysisComment(context, prNumber) + await upsertComment(context, existingComment, formattedAnalysis, prNumber) +} + +export async function upsertComment( + context: Context, + existingComment: SingleComment, + formattedAnalysis: string, + prNumber: number +) { + const repo = context.repo() + if (existingComment) { // Update the existing comment await context.octokit.issues.updateComment({ diff --git a/src/comment-handlers/line-comments-handler.ts b/src/comment-handlers/line-comments-handler.ts index 7b19c6d..c7be318 100644 --- a/src/comment-handlers/line-comments-handler.ts +++ b/src/comment-handlers/line-comments-handler.ts @@ -1,6 +1,9 @@ import { type Context } from 'probot' import { z } from 'zod' -import { globalCommentHandler } from './global-comment-handler.ts' +import { + globalCommentHandler, + upsertComment +} from './global-comment-handler.ts' // Marker for the global summary comment const SUMMARY_MARKER = '' @@ -112,21 +115,8 @@ export async function lineCommentsHandler( // Handle the summary comment (global PR comment) const existingSummary = await findExistingSummaryComment(context, prNumber) - if (existingSummary) { - // Update existing summary - await context.octokit.issues.updateComment({ - ...repo, - comment_id: existingSummary.id, - body: formattedSummary - }) - } else { - // Create new summary - await context.octokit.issues.createComment({ - ...repo, - issue_number: prNumber, - body: formattedSummary - }) - } + + await upsertComment(context, existingSummary, formattedSummary, prNumber) // Get the commit SHA for the PR head const { data: pullRequest } = await context.octokit.pulls.get({