diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..7400c9e7 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,24 @@ +name : AI Code Reviewer + +on: + pull_request: + types: + -open + -synchronize + +permissions: write-all +jobs: + review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: AI Code Reviewer + uses: Shubhadarsini/ai-code-reviewer@main + + with: + GITHUB_TOKEN: {{secrets.GITHUB_TOKEN}} + OPENAI_API_KEY: {{secrets.OPENAI_API_KEY}} + OPENAI_MODEL: "gpt-4" + exclude: "**/*.json, **/*.md" \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..7a9dfa04 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/src/shubha-test.ts b/src/shubha-test.ts new file mode 100644 index 00000000..fe3fb9ee --- /dev/null +++ b/src/shubha-test.ts @@ -0,0 +1,249 @@ +import { readFileSync } from "fs"; +import * as core from "@actions/core"; +import OpenAI from "openai"; +import { Octokit } from "@octokit/rest"; +import parseDiff, { Chunk, File } from "parse-diff"; +import minimatch from "minimatch"; + +const GITHUB_TOKEN: string = core.getInput("GITHUB_TOKEN"); +const OPENAI_API_KEY: string = core.getInput("OPENAI_API_KEY"); +const OPENAI_API_MODEL: string = core.getInput("OPENAI_API_MODEL"); + +const octokit = new Octokit({ auth: GITHUB_TOKEN }); + +const openai = new OpenAI({ + apiKey: OPENAI_API_KEY, +}); + +interface PRDetails { + owner: string; + repo: string; + pull_number: number; + title: string; + description: string; +} + +async function getPRDetails(): Promise { + const { repository, number } = JSON.parse( + 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: string, + repo: string, + pull_number: number +): Promise { + 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: File[], + prDetails: PRDetails +): Promise> { + 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 prompt = createPrompt(file, chunk, prDetails); + const aiResponse = await getAIResponse(prompt); + if (aiResponse) { + const newComments = createComment(file, chunk, aiResponse); + if (newComments) { + comments.push(...newComments); + } + } + } + } + return comments; +} + +function createPrompt(file: File, chunk: Chunk, prDetails: PRDetails): string { + return `Your task is to review pull requests. Instructions: +- Provide the response in following JSON format: {"reviews": [{"lineNumber": , "reviewComment": ""}]} +- Do not give positive comments or compliments. +- Provide comments and suggestions ONLY if there is something to improve, otherwise "reviews" should be an empty array. +- Write the comment in GitHub Markdown format. +- Use the given description only for the overall context and only comment the code. +- IMPORTANT: NEVER suggest adding comments to the code. + +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")} +\`\`\` +`; +} + +async function getAIResponse(prompt: string): Promise | null> { + const queryConfig = { + model: OPENAI_API_MODEL, + temperature: 0.2, + max_tokens: 700, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + }; + + try { + const response = await openai.chat.completions.create({ + ...queryConfig, + // return JSON if the model supports it: + ...(OPENAI_API_MODEL === "gpt-4-1106-preview" + ? { response_format: { type: "json_object" } } + : {}), + messages: [ + { + role: "system", + content: prompt, + }, + ], + }); + + const res = response.choices[0].message?.content?.trim() || "{}"; + return JSON.parse(res).reviews; + } catch (error) { + console.error("Error:", error); + return null; + } +} + +function createComment( + file: File, + chunk: Chunk, + aiResponses: Array<{ + lineNumber: string; + reviewComment: string; + }> +): Array<{ body: string; path: string; line: number }> { + return aiResponses.flatMap((aiResponse) => { + if (!file.to) { + return []; + } + return { + body: aiResponse.reviewComment, + path: file.to, + line: Number(aiResponse.lineNumber), + }; + }); +} + +async function createReviewComment( + owner: string, + repo: string, + pull_number: number, + comments: Array<{ body: string; path: string; line: number }> +): Promise { + await octokit.pulls.createReview({ + owner, + repo, + pull_number, + comments, + event: "COMMENT", + }); +} + +async function mainfun() { + const prDetails = await getPRDetails(); + let diff: string | null; + const eventData = JSON.parse( + 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 = parseDiff(diff); + + const excludePatterns = core + .getInput("exclude") + .split(",") + .map((s) => s.trim()); + + const filteredDiff = parsedDiff.filter((file) => { + return !excludePatterns.some((pattern) => + 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); +});