diff --git a/.github/workflows/code-review.yaml b/.github/workflows/code-review.yaml new file mode 100644 index 0000000..d128e55 --- /dev/null +++ b/.github/workflows/code-review.yaml @@ -0,0 +1,25 @@ +name: Code Reviewer +run-name: Action started by ${{ github.actor }} + +on: + pull_request: + types: + - opened + - synchronize + +permissions: write-all + +jobs: + code-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Code Review the pull request + uses: ./code-review + with: + token: ${{ secrets.GITHUB_TOKEN }} + openai_key: ${{ secrets.OPENAI_KEY }} + exclude: "**/*.json, **/*.md, **/*.g.dart" + append_prompt: "Give a maximum of 3 suggestions" \ No newline at end of file diff --git a/code-review/README.md b/code-review/README.md new file mode 100644 index 0000000..cf94bbe --- /dev/null +++ b/code-review/README.md @@ -0,0 +1,66 @@ +# Code Reviewer + +Code Reviewer is a GitHub Action that leverages OpenAI's GPT API to provide intelligent feedback and suggestions on +your pull requests. This powerful tool helps improve code quality and saves developers time by automating the code +review process. + +## Features + +- Reviews pull requests using OpenAI's chat GPT API. +- Provides intelligent comments and suggestions for improving your code. +- Filters out files that match specified exclude patterns. +- Easy to set up and integrate into your GitHub workflow. + +## Setup + +1. To use this GitHub Action, you need an OpenAI API key. If you don't have one, sign up for an API key + at [OpenAI](https://beta.openai.com/signup). + +2. Add the OpenAI API key as a GitHub Secret in your repository with the name `openai_key`. You can find more + information about GitHub Secrets [here](https://docs.github.com/en/actions/reference/encrypted-secrets). + +3. Create a `.github/workflows/main.yml` file in your repository and add the following content: + +```yaml +name: Code Reviewer +run-name: Action started by ${{ github.actor }} + +on: + pull_request: + types: + - opened + - synchronize + +permissions: write-all + +jobs: + review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: AI Code Reviewer + uses: emanuel-braz/github-actions/code-review@0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + openai_key: ${{ secrets.OPENAI_KEY }} + exclude: "**/*.json, **/*.md, **/*.g.dart" # Optional: exclude patterns separated by commas +``` + +- Customize the `exclude` input if you want to ignore certain file patterns from being reviewed. + +- Commit the changes to your repository, and AI Code Reviewer will start working on your future pull requests. + +## How It Works + +The AI Code Reviewer GitHub Action retrieves the pull request diff, filters out excluded files, and sends code chunks to +the OpenAI API. It then generates review comments based on the AI's response and adds them to the pull request. + +## Contributing + +Contributions are welcome! Please feel free to submit issues or pull requests to improve the GitHub Actions. + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. \ No newline at end of file diff --git a/code-review/action.js b/code-review/action.js new file mode 100644 index 0000000..2c0c5c3 --- /dev/null +++ b/code-review/action.js @@ -0,0 +1,243 @@ +require('child_process') + .execSync( + 'npm install @actions/core @actions/github parse-diff minimatch fs', + { cwd: __dirname } + ); + +let parse = require('parse-diff'); +const minimatch = require('minimatch'); +const core = require('@actions/core'); +const github = require('@actions/github'); +const fs = require('fs'); +const SimpleChatGptService = require('../services/simple_chat_gpt_service.js'); +const ChatCompletionParams = require('../services/gpt/chat_completion_params.js'); +const Logger = require('../utils/logger.js'); + +const logger = new Logger(true, core); +const GITHUB_TOKEN = core.getInput("token"); +const OPENAI_API_KEY = core.getInput("openai_key"); +const OPENAI_API_MODEL = core.getInput("openai_key_model"); +const overridePrompt = core.getInput("override_prompt"); +const appendPrompt = core.getInput("append_prompt"); +const excludePatterns = core + .getInput("exclude") + .split(",") + .map((s) => s.trim()); + +const octokit = github.getOctokit(GITHUB_TOKEN); + +async function getPRDetails() { + const { repository, number } = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH || "", "utf8") + ); + const prResponse = await octokit.pulls.get({ + owner: repository.owner.login, + repo: repository.name, + pull_number: number, + }); + return { + owner: repository.owner.login, + repo: repository.name, + pull_number: number, + title: prResponse.data.title ?? "", + description: prResponse.data.body ?? "", + }; +} + +async function getDiff(owner, repo, pull_number) { + const response = await octokit.pulls.get({ + owner, + repo, + pull_number, + mediaType: { format: "diff" }, + }); + // @ts-expect-error - response.data is a string + return response.data; +} + +async function analyzeCode(parsedDiff, prDetails) { + const comments = []; //Array<{ body: string; path: string; line: number }> + + for (const file of parsedDiff) { + if (file.to === "/dev/null") continue; // Ignore deleted files + for (const chunk of file.chunks) { + const messages = createMessages(file, chunk, prDetails); + const aiResponse = await getAIResponse(messages); + if (aiResponse) { + const newComments = createComment(file, chunk, aiResponse); + if (newComments) { + comments.push(...newComments); + } + } + } + } + return comments; +} + +function createMessages(file, chunk, prDetails) { + const instructionJsonFormat = `- Provide the response in following JSON format: [{"lineNumber": , "reviewComment": ""}]`; + + var contentSystemMessage = `You are a senior software engineer and your task is to review pull requests for possible bugs or bad development practices. Follow the instructions below: +- You should NEVER give positive comments or compliments. +- You should NEVER suggest removing empty line. +- You should NEVER suggest to remove trailing or leading whitespace. +- You should NEVER suggest adding comment to describe the purpose of methods or functions. +- You ONLY will provide comments and suggestions if there is something to improve, otherwise return an empty array. +- You must write the comment in GitHub Markdown format. +- Do use the given pull request title and description only for the overall context and only comment the code.`; + + if (overridePrompt) { + contentSystemMessage = overridePrompt; + } + + contentSystemMessage = `${contentSystemMessage}\n${instructionJsonFormat}`; + + if (appendPrompt) { + contentSystemMessage = `${contentSystemMessage}\n\n${appendPrompt}`; + } + + + var systemPrompt = + { + content: contentSystemMessage, + role: "system", + }; + + let userPrompt = + { + content: `Review the following code diff in the file "${file.to}" and take the pull request title and description into account when writing the response. + +Pull request title: ${prDetails.title} +Pull request description: + +--- +${prDetails.description} +--- + +Git diff to review: + +\`\`\`diff +${chunk.content} +${chunk.changes + // @ts-expect-error - ln and ln2 exists where needed + .map((c) => `${c.ln ? c.ln : c.ln2} ${c.content}`) + .join("\n")} +\`\`\` + `, + role: "user", + }; + + return [systemPrompt, userPrompt]; +} + +async function getAIResponse(messages) { + + try { + const chatCompletionParams = new ChatCompletionParams({ + messages: messages, + model: OPENAI_API_MODEL, + temperature: 0, + max_tokens: 900, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + }); + + const simpleChatGptService = new SimpleChatGptService(OPENAI_API_KEY); + const response = await simpleChatGptService.fromParams({ chatCompletionParams }); + + const result = response?.trim() || "[]"; + logger.log(`AI response: ${result}`); + return JSON.parse(result); + } catch (error) { + console.error("Error:", error); + return null; + } +} + +// Array<{ body: string; path: string; line: number }> +function createComment(file, chunk, aiResponses) { + return aiResponses.flatMap((aiResponse) => { + if (!file.to) { + return []; + } + return { + body: aiResponse.reviewComment, + path: file.to, + line: Number(aiResponse.lineNumber), + }; + }); +} + +async function createReviewComment(owner, repo, pull_number, comments) { + await octokit.pulls.createReview({ + owner, + repo, + pull_number, + comments, + event: "COMMENT", + }); +} + +async function main() { + + const prDetails = await getPRDetails(); + let diff; // string | null + const eventData = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH ?? "", "utf8") + ); + + if (eventData.action === "opened") { + diff = await getDiff( + prDetails.owner, + prDetails.repo, + prDetails.pull_number + ); + } else if (eventData.action === "synchronize") { + const newBaseSha = eventData.before; + const newHeadSha = eventData.after; + + const response = await octokit.repos.compareCommits({ + headers: { + accept: "application/vnd.github.v3.diff", + }, + owner: prDetails.owner, + repo: prDetails.repo, + base: newBaseSha, + head: newHeadSha, + }); + + diff = String(response.data); + } else { + console.log("Unsupported event:", process.env.GITHUB_EVENT_NAME); + return; + } + + if (!diff) { + console.log("No diff found"); + return; + } + + const parsedDiff = parse(diff); + + const filteredDiff = parsedDiff.filter((file) => { + return !excludePatterns.some((pattern) => + minimatch.minimatch(file.to ?? "", pattern) + ); + }); + + const comments = await analyzeCode(filteredDiff, prDetails); + if (comments.length > 0) { + await createReviewComment( + prDetails.owner, + prDetails.repo, + prDetails.pull_number, + comments + ); + } +} + +main().catch((error) => { + console.error("Error:", error); + process.exit(1); +}); diff --git a/code-review/action.yml b/code-review/action.yml new file mode 100644 index 0000000..91fbcb4 --- /dev/null +++ b/code-review/action.yml @@ -0,0 +1,31 @@ +name: emanuel-braz/code-review +description: "Perform code reviews and comment on diffs using OpenAI API." +author: Emanuel Braz +branding: + icon: send + color: gray-dark +inputs: + token: + description: "GitHub token to interact with the repository." + required: true + openai_key: + description: "OpenAI API key for GPT." + required: true + openai_key_model: + description: "OpenAI API model." + required: false + default: "gpt-3.5-turbo" + exclude: + description: "Glob patterns to exclude files from the diff analysis" + required: false + default: "" + override_prompt: + description: "The text to be used to override the default prompt." + required: false + append_prompt: + description: "The text to be used to append to the default prompt." + required: false + +runs: + using: "node16" + main: ./action.js diff --git a/services/simple_chat_gpt_service.js b/services/simple_chat_gpt_service.js index 0ceb1ed..b84e466 100644 --- a/services/simple_chat_gpt_service.js +++ b/services/simple_chat_gpt_service.js @@ -29,6 +29,18 @@ class SimpleChatGptService { throw error; } } + + // chatCompletionParams: ChatCompletionParams + async fromParams({ chatCompletionParams }) { + try { + const response = await this.service.chatCompletions(chatCompletionParams); + const message = response.choices[0].message.content; + return message; + } catch (error) { + console.error('[SimpleChatGptService]', error); + throw error; + } + } } module.exports = SimpleChatGptService; \ No newline at end of file