diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml index 8ac5e76..49aadf2 100644 --- a/.github/workflows/action.yml +++ b/.github/workflows/action.yml @@ -10,13 +10,19 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v2 - - name: Set up Node - uses: actions/setup-node@v2 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - name: Install dependencies + run: npm install + - name: Build Code + run: npm run build - name: Run Custom Action uses: ./ with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} pr-number: ${{ github.event.number }} - openai_api_key: ${{ secrets.OPENAI_API_KEY }} + openai_api_key: ${{ secrets.OPEN_AI_KEY }} openai_model: 'gpt-4' review_code: true + expluded_files: 'node_modules, package.json, package-lock.json' diff --git a/action.yml b/action.yml index 019f325..4221c32 100644 --- a/action.yml +++ b/action.yml @@ -2,12 +2,13 @@ name: 'Github PR Magic' description: 'Automatically reviewing and approving PRs' author: 'Darrell Richards' inputs: - github-token: + github_token: description: 'Github token' required: true - pr-number: - description: 'PR number' - required: true + excluded_files: + description: 'Files to exclude from review' + required: false + default: 'node_modules, package-lock.json, yarn.lock' openai_api_key: description: 'OpenAI API key' required: true @@ -19,6 +20,14 @@ inputs: description: 'Review code' required: false default: true + generate_summary: + description: 'Generates Pull Request summary based on git diff and code changes' + required: false + default: false + overall_code_review: + description: 'Overall code review as a comment' + required: false + default: false runs: using: 'node20' main: 'lib/index.js' \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 25e9beb..3734b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,11 @@ "dependencies": { "@actions/core": "^1.10.1", "@octokit/rest": "^20.1.1", + "@octokit/types": "^13.5.0", "@tandil/diffparse": "^0.2.0", "minimatch": "^9.0.4", - "openai": "^4.47.3" + "openai": "^4.47.3", + "parse-diff": "^0.11.1" }, "devDependencies": { "typescript": "^5.4.5" @@ -434,6 +436,11 @@ "openai": "bin/cli" } }, + "node_modules/parse-diff": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/parse-diff/-/parse-diff-0.11.1.tgz", + "integrity": "sha512-Oq4j8LAOPOcssanQkIjxosjATBIEJhCxMCxPhMu+Ci4wdNmAEdx0O+a7gzbR2PyKXgKPvRLIN5g224+dJAsKHA==" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index 02e305e..d8bfa81 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "dependencies": { "@actions/core": "^1.10.1", "@octokit/rest": "^20.1.1", + "@octokit/types": "^13.5.0", "@tandil/diffparse": "^0.2.0", "minimatch": "^9.0.4", - "openai": "^4.47.3" + "openai": "^4.47.3", + "parse-diff": "^0.11.1" } } diff --git a/src/index.ts b/src/index.ts index 03300af..df61629 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,156 @@ -const parser = require('@tandil/diffparse'); import { readFileSync } from "fs" -import OpenAI from "openai" -import core from "@actions/core"; -import { PRDetails } from "./lib/github"; -import { minimatch } from "minimatch"; +import parseDiff, { File } from "parse-diff" +import * as core from "@actions/core" + +import { commentOnPullRequest, compareCommits, createReviewComment, gitDiff, PRDetails, updateBody } from "./services/github"; +import { filter, minimatch } from "minimatch"; +import { obtainFeedback, prSummaryCreation, summaryAllMessages, summaryOfAllFeedback, validateCodeViaAI } from "./services/ai"; + + +const excludedFiles = core.getInput("excluded_files").split(",").map((s: string) => s.trim()); +const createPullRequestSummary = core.getInput("generate_summary"); +const reviewCode = core.getInput("review_code") +const overallReview = core.getInput("overall_code_review"); + + +export interface Details { + title: string; + description: string; +} + +async function validatePullRequest(diff: File[], details: Details) { + const foundSummary = []; + for (const file of diff) { + for (const chunk of file.chunks) { + const message = await prSummaryCreation(file, chunk, details); + if (message) { + const mappedResults = message.flatMap((result: any) => { + if (!result.changes) { + return []; + } + + if (!result.typeChanges) { + return []; + } + + if (!result.checklist) { + return []; + } + + + return { + changes: result.changes, + typeChanges: result.typeChanges, + checklist: result.checklist, + }; + }); + + foundSummary.push(...mappedResults); + } + } + } + + + if (foundSummary && foundSummary.length > 0) { + const bodyIdea = await summaryAllMessages(foundSummary); + return bodyIdea; + } + + return ''; +} + +async function validateCode(diff: File[], details: Details) { + const neededComments = []; + for (const file of diff) { + for (const chunk of file.chunks) { + const results = await validateCodeViaAI(file, chunk, details); + + if (results) { + const mappedResults = results.flatMap((result: any) => { + if (!file.to) { + return []; + } + + if (!result.lineNumber) { + return []; + } + + if (!result.review) { + return []; + } + + return { + body: result.review, + path: file.to, + position: Number(result.lineNumber) + }; + }); + + if (mappedResults) { + neededComments.push(...mappedResults); + } + } + } + } + + return neededComments; +} + +async function validateOverallCodeReview(diff: File[], details: Details) { + const detailedFeedback = []; + for (const file of diff) { + for (const chunk of file.chunks) { + const results = await obtainFeedback(file, chunk, details); + if (results) { + const mappedResults = results.flatMap((result: any) => { + if (!file.to) { + return []; + } + + return { + changesOverview: result.changesOverview, + feedback: result.feedback, + improvements: result.improvements, + conclusion: result.conclusion, + }; + }); + + if (mappedResults) { + detailedFeedback.push(...mappedResults); + } + } + } + } + + return detailedFeedback; +} -const openai = new OpenAI() -const excludedFiles = core.getInput("exclude").split(",").map((s: string) => s.trim()); async function main() { let dif: string | null = null; - const { action, repository, number } = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH || "", "utf-8")) - const { title, description } = await PRDetails(repository, number); + const { action, repository, number, before, after } = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH || "", "utf-8")) + const { title, description, patch_url, diff_url } = await PRDetails(repository, number); if (action === "opened") { // Generate a summary of the PR since it's a new PR - + const data = await gitDiff(repository.owner.login, repository.name, number); + dif = data as unknown as string; + } else if (action === "synchronize") { + const newBaseSha = before; + const newHeadSha = after; + + const data = await compareCommits({ + owner: repository.owner.login, + repo: repository.name, + before: newBaseSha, + after: newHeadSha, + number + }) + + dif = String(data); + } else { + console.log('Unknown action', process.env.GITHUB_EVENT_NAME); + return; } if (!dif) { @@ -23,18 +158,57 @@ async function main() { return; } - const diff = parser.parseDiffString(dif); - const filteredDiff = diff.filter((file: { to: any; }) => { + const diff = parseDiff(dif); + const filteredDiff = diff.filter((file) => { return !excludedFiles.some((pattern) => minimatch(file.to ?? "", pattern) ); }); - console.log(filteredDiff); + if (action === "opened") { + if (createPullRequestSummary) { + console.log('Generating summary for new PR'); + const summary = await validatePullRequest(diff, { + title, + description + }); + + await updateBody(repository.owner.login, repository.name, number, summary) + } - // Validate Some Code Yo! + if (overallReview) { + const detailedFeedback = await validateOverallCodeReview(filteredDiff, { + title, + description + }); + + if (detailedFeedback && detailedFeedback.length > 0) { + const resultsFullFeedback = await summaryOfAllFeedback(detailedFeedback); + + await commentOnPullRequest({ + owner: repository.owner.login, + repo: repository.name, + number + }, resultsFullFeedback); + } + } + } - // Post some comments + if (reviewCode) { + // @TODO Improve the support for comments, + // IE: Remove outdated comments when code is changed, revalidated if the Pull Request is ready to be approved. + const neededComments = await validateCode(filteredDiff, { + title, + description + }); + if (neededComments && neededComments.length > 0) { + await createReviewComment(repository.owner.login, repository.name, number, neededComments); + } else { + // @TODO We need to veirfy if any other comments was created by the AI Bot, if so see if they was updated. + // @TODO If they have been fixed or no reviews are required than we can approve the Pull Request + await createReviewComment(repository.owner.login, repository.name, number, neededComments) + } + } } -main(); \ No newline at end of file +main(); diff --git a/src/services/ai.ts b/src/services/ai.ts new file mode 100644 index 0000000..98eed00 --- /dev/null +++ b/src/services/ai.ts @@ -0,0 +1,222 @@ +import { Chunk, File } from "parse-diff"; +import * as core from '@actions/core'; +import { Details } from ".."; +import OpenAI from "openai"; + +const OPEN_AI_KEY: string = core.getInput("openai_api_key"); + +const openai = new OpenAI({ + apiKey: OPEN_AI_KEY, +}) + +export async function createMessage(file: File, chunk: Chunk, details: Details) { + const message = ` + Your requirement is to review pull request. + Instructions below: + - Provide response in the Following JSON format: {"reviews": [{"lineNumber": , "review": "", "required_changed": ""}]}. + - Do not give positive comments or compliments. + - Provide comments and suggestions IF there is something to improve, otherwise "reviews" should be a empty array of reviews. + - If you want to request changes that should be required, set "required_changed" to true. + - REQUIRED: Do not suggest adding comments to the code. + - REQUIRED: Do not suggest adding a new line at the end of a file. + - Please write comment in Github Markdown Format. + - Use the given pr description only for the overall context + + Review the following code diff in the file "${file.to}" and take the pull request title into account when writing your response. + + Pull Request title: ${details.title} + + Pull Request description: + + ${details.description} + + Git diff to review: + + ${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")} + + ` + + return message; +} + +export async function prSummaryCreation(file: File, chunk: Chunk, details: Details) { + const message = ` + Your requirement is to create a Pull Request Summary for this Pull Request. + Instructions below: + - Provide a detailed summary of the pull request based on the diff url below. + - Please write the result in Github Markdown Format. + - Provide the written summary in the following JSON format: {"summary": [{"changes": "", "typeChanges": ""}]}. + + + Review the following code diff in the files "${file.to}", and take the pull request title: ${details.title} into account when writing your response. + + Pull Request title: ${details.title} + + Files to review: ${file.to} + + Git diff to review: + + ${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")} + ` + + const response = await openai.chat.completions.create({ + model: "gpt-4-1106-preview", + response_format: { + type: "json_object", + }, + messages: [ + { + role: "system", + content: message, + }, + ], + }); + + const resss = response.choices[0].message?.content?.trim() || "{}"; + // console.log('resss', JSON.parse(resss)); + return JSON.parse(resss).summary; +} + +export async function obtainFeedback(file: File, chunk: Chunk, details: Details) { + const message = ` + Your requirement is to create a Feedback for this Pull Request. + Instructions below: + - Provide a detailed feedback of the pull request based on the diff below. + - Please write the result in Github Markdown Format. + - Provide the written feedback in the following JSON format: {"feedback": [{"changesOverview": "", "feedback": "", "conclusion": ""}]}. + + + Review the following code diff in the files "${file.to}", and take the pull request title: ${details.title} into account when writing your response. + + Pull Request title: ${details.title} + + Files to review: ${file.to} + + Git diff to review: + + ${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")} + ` + + const response = await openai.chat.completions.create({ + model: "gpt-4-1106-preview", + response_format: { + type: "json_object", + }, + messages: [ + { + role: "system", + content: message, + }, + ], + }); + + const resss = response.choices[0].message?.content?.trim() || "{}"; + return JSON.parse(resss).feedback; +} + +export async function summaryOfAllFeedback(feedbacks: any[]) { + console.log('feedbacks', feedbacks); + const systemMessage = ` + Your requirement is to merge all the feedbacks into one feedback. + Instructions below: + - Please write the result in Github Markdown Format. + - Provide the written feedback written as a Github Comment format. + - Please format each header as a H2 header. + ` + const message = ` + Feedback to review: + ${feedbacks.map((f) => f.changesOverview).join(", ")} + ${feedbacks.map((f) => f.feedback).join(", ")} + ${feedbacks.map((f) => f.improvements).join(", ")} + ${feedbacks.map((f) => f.conclusion).join(", ")} + ` + + const response = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "system", + content: systemMessage, + }, + { + role: "user", + content: message, + }, + ], + }); + + const resss = response.choices[0].message?.content?.trim() || "{}"; + return resss; +} + +export async function summaryAllMessages(summaries: any[]) { + const systemMessage = ` + Your requirement is to merge all the summaries into one summary. + Instructions below: + - Please write the result in Github Markdown Format. + - Provide the written summary written as a Github Pull Request Body. + ` + const message = ` + Summaries to review: ${summaries.map((s) => s.changes).join(", ")} + ` + + const response = await openai.chat.completions.create({ + model: "gpt-4o", + messages: [ + { + role: "system", + content: systemMessage, + }, + { + role: "user", + content: message, + }, + ], + }); + + const resss = response.choices[0].message?.content?.trim() || "{}"; + return resss; +} + + +export async function validateCodeViaAI(file: File, chunk: Chunk, details: Details) { + try { + const message = await createMessage(file, chunk, details); + const response = await openai.chat.completions.create({ + model: "gpt-4-1106-preview", + response_format: { + type: "json_object", + }, + messages: [ + { + role: "system", + content: message, + }, + ], + }); + + + const resss = response.choices[0].message?.content?.trim() || "{}"; + return JSON.parse(resss).reviews; + } catch (error) { + console.log('validateCodeViaAI error', error) + } + + + + // const response = await aiService(message); + // console.log('response', response); + // return response; +} diff --git a/src/services/github.ts b/src/services/github.ts new file mode 100644 index 0000000..630bc42 --- /dev/null +++ b/src/services/github.ts @@ -0,0 +1,124 @@ +import { Octokit } from '@octokit/rest'; +import * as core from "@actions/core" +import { RequestError } from '@octokit/types'; +const GITHUB_TOKEN: string = core.getInput("github_token"); + +const octokit = new Octokit({ + auth: GITHUB_TOKEN, +}); + + +export interface Event { + owner: string; + number: number; + repo: any; + before?: string; + after?: string; +} + + +export async function PRDetails(repository: any, number: number) { + // Obtain the PR details + const { data } = await octokit.pulls.get({ + owner: repository.owner.login, + repo: repository.name, + pull_number: number, + }); + + // console.log('data', data); + + return { + title: data.title || "", + description: data.body || "", + patch_url: data.patch_url || "", + diff_url: data.diff_url || "", + }; +} + +export async function commentOnPullRequest(event: Event, body: string) { + const regexForReplacing = /```markdown(.*?)```/gms; + const {data} = await octokit.rest.issues.createComment({ + owner: event.owner, + repo: event.repo, + issue_number: event.number, + body: body.replace(regexForReplacing, ""), + }); + + console.log('commentOnPullRequest', data); +} + +export async function updateBody(owner: string, repo: string, pull_number: number, body: string) { + const regexForReplacing = /```markdown(.*?)```/gms; + try { + const { data } = await octokit.pulls.update({ + owner, + repo, + pull_number, + body: body.replace(regexForReplacing, ""), + }); + console.log('updateBody', data); + } catch (error) { + console.log('updateBody error', error); + } +} + + +export async function gitDiff(owner: string, repo: string, pull_number: number) { + // Obtain the PR details + const { data } = await octokit.pulls.get({ + owner, + repo, + pull_number, + mediaType: { format: "diff" }, + }); + + return data; +} + + +export async function createReviewComment(owner: string, repo: string, pull_number: number, comments: any[]) { + try { + if (comments.length === 0) { + const { data } = await octokit.pulls.createReview({ + owner, + repo, + pull_number, + event: "APPROVE", + }); + + return data; + }; + const { data } = await octokit.pulls.createReview({ + owner, + repo, + pull_number, + event: "REQUEST_CHANGES", + comments, + }); + return data; + } catch (error) { + console.log('basicError', error); + const newError = error as RequestError; + if (newError.errors) { + for (let index = 0; index < newError.errors.length; index++) { + const error = newError.errors[index]; + console.log('createReviewComment error loops', error); + } + } + console.log('createReviewComment error', newError); + } +} + +export async function compareCommits(event: Event) { + const { data } = await octokit.repos.compareCommits({ + headers: { + accept: "application/vnd.github.v3.diff", + }, + owner: event.owner, + repo: event.repo, + base: event.before || "", + head: event.after || "", + }); + + return data; +}