This commit is contained in:
Michael Lahr 2024-03-14 10:58:32 +08:00
commit 0ab6d2521f

View file

@ -1,85 +1,85 @@
import { readFileSync } from "fs"; import {readFileSync} from "fs";
import * as core from "@actions/core"; import * as core from "@actions/core";
import OpenAI from "openai"; import OpenAI from "openai";
import { Octokit } from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import parseDiff, { Chunk, File } from "parse-diff"; import parseDiff, {Chunk, File} from "parse-diff";
import minimatch from "minimatch"; import minimatch from "minimatch";
const GITHUB_TOKEN: string = core.getInput("GITHUB_TOKEN"); const GITHUB_TOKEN: string = core.getInput("GITHUB_TOKEN");
const OPENAI_API_KEY: string = core.getInput("OPENAI_API_KEY"); const OPENAI_API_KEY: string = core.getInput("OPENAI_API_KEY");
const OPENAI_API_MODEL: string = core.getInput("OPENAI_API_MODEL"); const OPENAI_API_MODEL: string = core.getInput("OPENAI_API_MODEL");
const octokit = new Octokit({ auth: GITHUB_TOKEN }); const octokit = new Octokit({auth: GITHUB_TOKEN});
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: OPENAI_API_KEY, apiKey: OPENAI_API_KEY,
}); });
interface PRDetails { interface PRDetails {
owner: string; owner: string;
repo: string; repo: string;
pull_number: number; pull_number: number;
title: string; title: string;
description: string; description: string;
} }
async function getPRDetails(): Promise<PRDetails> { async function getPRDetails(): Promise<PRDetails> {
const { repository, number } = JSON.parse( const {repository, number} = JSON.parse(
readFileSync(process.env.GITHUB_EVENT_PATH || "", "utf8") readFileSync(process.env.GITHUB_EVENT_PATH || "", "utf8")
); );
const prResponse = await octokit.pulls.get({ const prResponse = await octokit.pulls.get({
owner: repository.owner.login, owner: repository.owner.login,
repo: repository.name, repo: repository.name,
pull_number: number, pull_number: number,
}); });
return { return {
owner: repository.owner.login, owner: repository.owner.login,
repo: repository.name, repo: repository.name,
pull_number: number, pull_number: number,
title: prResponse.data.title ?? "", title: prResponse.data.title ?? "",
description: prResponse.data.body ?? "", description: prResponse.data.body ?? "",
}; };
} }
async function getDiff( async function getDiff(
owner: string, owner: string,
repo: string, repo: string,
pull_number: number pull_number: number
): Promise<string | null> { ): Promise<string | null> {
const response = await octokit.pulls.get({ const response = await octokit.pulls.get({
owner, owner,
repo, repo,
pull_number, pull_number,
mediaType: { format: "diff" }, mediaType: {format: "diff"},
}); });
// @ts-expect-error - response.data is a string // @ts-expect-error - response.data is a string
return response.data; return response.data;
} }
async function analyzeCode( async function analyzeCode(
parsedDiff: File[], parsedDiff: File[],
prDetails: PRDetails prDetails: PRDetails
): Promise<Array<{ body: string; path: string; line: number }>> { ): Promise<Array<{ body: string; path: string; line: number }>> {
const comments: Array<{ body: string; path: string; line: number }> = []; const comments: Array<{ body: string; path: string; line: number }> = [];
for (const file of parsedDiff) { for (const file of parsedDiff) {
if (file.to === "/dev/null") continue; // Ignore deleted files if (file.to === "/dev/null") continue; // Ignore deleted files
for (const chunk of file.chunks) { for (const chunk of file.chunks) {
const prompt = createPrompt(file, chunk, prDetails); const prompt = createPrompt(file, chunk, prDetails);
const aiResponse = await getAIResponse(prompt); const aiResponse = await getAIResponse(prompt);
if (aiResponse) { if (aiResponse) {
const newComments = createComment(file, chunk, aiResponse); const newComments = createComment(file, chunk, aiResponse);
if (newComments) { if (newComments) {
comments.push(...newComments); comments.push(...newComments);
}
}
} }
}
} }
} return comments;
return comments;
} }
function createPrompt(file: File, chunk: Chunk, prDetails: PRDetails): string { function createPrompt(file: File, chunk: Chunk, prDetails: PRDetails): string {
return `Your task is to review pull requests. Instructions: return `Your task is to review pull requests. Instructions:
- Provide the response in following JSON format: {"reviews": [{"lineNumber": <line_number>, "reviewComment": "<review comment>"}]} - Provide the response in following JSON format: {"reviews": [{"lineNumber": <line_number>, "reviewComment": "<review comment>"}]}
- Do not give positive comments or compliments. - 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. - Provide comments and suggestions ONLY if there is something to improve, otherwise "reviews" should be an empty array.
@ -88,8 +88,8 @@ function createPrompt(file: File, chunk: Chunk, prDetails: PRDetails): string {
- IMPORTANT: NEVER suggest adding comments to the code. - IMPORTANT: NEVER suggest adding comments to the code.
Review the following code diff in the file "${ Review the following code diff in the file "${
file.to file.to
}" and take the pull request title and description into account when writing the response. }" and take the pull request title and description into account when writing the response.
Pull request title: ${prDetails.title} Pull request title: ${prDetails.title}
Pull request description: Pull request description:
@ -103,147 +103,150 @@ Git diff to review:
\`\`\`diff \`\`\`diff
${chunk.content} ${chunk.content}
${chunk.changes ${chunk.changes
// @ts-expect-error - ln and ln2 exists where needed // @ts-expect-error - ln and ln2 exists where needed
.map((c) => `${c.ln ? c.ln : c.ln2} ${c.content}`) .map((c) => `${c.ln ? c.ln : c.ln2} ${c.content}`)
.join("\n")} .join("\n")}
\`\`\` \`\`\`
`; `;
} }
async function getAIResponse(prompt: string): Promise<Array<{ async function getAIResponse(prompt: string): Promise<Array<{
lineNumber: string; lineNumber: string;
reviewComment: string; reviewComment: string;
}> | null> { }> | null> {
const queryConfig = { const queryConfig = {
model: OPENAI_API_MODEL, model: OPENAI_API_MODEL,
temperature: 0.2, temperature: 0.2,
max_tokens: 700, max_tokens: 700,
top_p: 1, top_p: 1,
frequency_penalty: 0, frequency_penalty: 0,
presence_penalty: 0, presence_penalty: 0,
}; };
try { try {
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
...queryConfig, ...queryConfig,
// return JSON if the model supports it: // return JSON if the model supports it:
...(OPENAI_API_MODEL === "gpt-4-1106-preview" ...(OPENAI_API_MODEL === "gpt-4-1106-preview"
? { response_format: { type: "json_object" } } ? {response_format: {type: "json_object"}}
: {}), : {}),
messages: [ messages: [
{ {
role: "system", role: "system",
content: prompt, content: prompt,
}, },
], ],
}); });
const res = response.choices[0].message?.content?.trim() || "{}"; const res = response.choices[0].message?.content?.trim() || "{}";
return JSON.parse(res).reviews; return JSON.parse(res).reviews;
} catch (error) { } catch (error) {
console.error("Error:", error); console.error("Error:", error);
return null; return null;
} }
} }
function createComment( function createComment(
file: File, file: File,
chunk: Chunk, chunk: Chunk,
aiResponses: Array<{ aiResponses: Array<{
lineNumber: string; lineNumber: string;
reviewComment: string; reviewComment: string;
}> }>
): Array<{ body: string; path: string; line: number }> { ): Array<{ body: string; path: string; line: number }> {
return aiResponses.flatMap((aiResponse) => { return aiResponses.flatMap((aiResponse) => {
if (!file.to) { if (!file.to) {
return []; return [];
} }
return { return {
body: aiResponse.reviewComment, body: aiResponse.reviewComment,
path: file.to, path: file.to,
line: Number(aiResponse.lineNumber), line: Number(aiResponse.lineNumber),
}; };
}); });
} }
async function createReviewComment( async function createReviewComment(
owner: string, owner: string,
repo: string, repo: string,
pull_number: number, pull_number: number,
comments: Array<{ body: string; path: string; line: number }> comments: Array<{ body: string; path: string; line: number }>
): Promise<void> { ): Promise<void> {
await octokit.pulls.createReview({ await octokit.pulls.createReview({
owner, owner,
repo, repo,
pull_number, pull_number,
comments, comments,
event: "COMMENT", event: "COMMENT",
}); });
} }
async function main() { async function main() {
const prDetails = await getPRDetails(); const prDetails = await getPRDetails();
let diff: string | null; let diff: string | null;
const eventData = JSON.parse( const eventData = JSON.parse(
readFileSync(process.env.GITHUB_EVENT_PATH ?? "", "utf8") 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({ if (eventData.action === "opened") {
headers: { diff = await getDiff(
accept: "application/vnd.github.v3.diff", prDetails.owner,
}, prDetails.repo,
owner: prDetails.owner, prDetails.pull_number
repo: prDetails.repo, );
base: newBaseSha, } else if (eventData.action === "synchronize") {
head: newHeadSha, 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) => {
let isMatch = minimatch(file.to ?? "", pattern);
console.log(file+","+file.to+","+pattern+": "+isMatch)
return isMatch
}
);
}); });
diff = String(response.data); const comments = await analyzeCode(filteredDiff, prDetails);
} else { if (comments.length > 0) {
console.log("Unsupported event:", process.env.GITHUB_EVENT_NAME); await createReviewComment(
return; prDetails.owner,
} prDetails.repo,
prDetails.pull_number,
if (!diff) { comments
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) => { main().catch((error) => {
console.error("Error:", error); console.error("Error:", error);
process.exit(1); process.exit(1);
}); });