import { readFileSync } from "fs"; import * as core from "@actions/core"; import { Configuration, OpenAIApi } 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 octokit = new Octokit({ auth: GITHUB_TOKEN }); const configuration = new Configuration({ apiKey: OPENAI_API_KEY, }); const openai = new OpenAIApi(configuration); 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; } async function getChangedFiles( owner: string, repo: string, baseSha: string, headSha: string ): Promise { const response = await octokit.repos.compareCommits({ owner, repo, base: baseSha, head: headSha, }); return response.data.diff_url; } async function getBaseAndHeadShas( owner: string, repo: string, pull_number: number ): Promise<{ baseSha: string; headSha: string }> { const prResponse = await octokit.pulls.get({ owner, repo, pull_number, }); return { baseSha: prResponse.data.base.sha, headSha: prResponse.data.head.sha, }; } function createPrompt(file: File, chunk: Chunk, prDetails: PRDetails): string { return `- Provide the response in following JSON format: [{"lineNumber": , "reviewComment": ""}] - Do not give positive comments or compliments. - Do not suggest commenting the code. - Provide comments and suggestions ONLY if there is something to improve, otherwise return an empty array. - Write the comment in GitHub Markdown format. - Use the given description only for the overall context and only comment 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: "gpt-4", temperature: 0.2, max_tokens: 700, top_p: 1, frequency_penalty: 0, presence_penalty: 0, }; try { const response = await openai.createChatCompletion({ ...queryConfig, messages: [ { role: "system", content: prompt, }, ], }); const res = response.data.choices[0].message?.content?.trim() || "[]"; return JSON.parse(res); } 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 main() { const prDetails = await getPRDetails(); const { baseSha, headSha } = await getBaseAndHeadShas( prDetails.owner, prDetails.repo, prDetails.pull_number ); let diff: string | null; if (process.env.GITHUB_EVENT_NAME === "pull_request") { diff = await getDiff( prDetails.owner, prDetails.repo, prDetails.pull_number ); } else if (process.env.GITHUB_EVENT_NAME === "push") { const diffUrl = await getChangedFiles( prDetails.owner, prDetails.repo, baseSha, headSha ); if (diffUrl) { const diffResponse = await octokit.request({ url: diffUrl }); diff = diffResponse.data; } else { diff = null; } } 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); });