diff --git a/README.md b/README.md index 2d16f30..8031b68 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,5 @@ PR-Codex is currently free. --- Made by [dlabs](https://dlabs.app) + + diff --git a/app/github/route.ts b/app/github/route.ts index ccb20b2..8af196b 100644 --- a/app/github/route.ts +++ b/app/github/route.ts @@ -1,4 +1,5 @@ import { handleGithubAuth } from "@lib/handleGithubAuth" +import { replyIssueComment } from "@lib/replyIssueComment" import { summarizePullRequest } from "@lib/summarizePullRequest" import { NextRequest, NextResponse } from "next/server" @@ -9,9 +10,17 @@ export async function POST(req: NextRequest) { try { if (payload.action == "opened" || payload.action == "synchronize") { + // If a PR is opened or updated, summarize it const octokit = await handleGithubAuth(payload) await summarizePullRequest(payload, octokit) + } else if (payload.action == "created") { + if (payload.comment.body.includes("/ask-codex")) { + // If a comment is created, reply to it + const octokit = await handleGithubAuth(payload) + + await replyIssueComment(payload, octokit) + } } return NextResponse.json("ok") diff --git a/lib/joinStringsUntilMaxLength.ts b/lib/joinStringsUntilMaxLength.ts index d96277b..3206b06 100644 --- a/lib/joinStringsUntilMaxLength.ts +++ b/lib/joinStringsUntilMaxLength.ts @@ -1,22 +1,24 @@ export function joinStringsUntilMaxLength( parsedFiles: string[], maxLength: number -): string { - let combinedString = "" +) { + let codeDiff = "" let currentLength = 0 + let maxLengthExceeded = false for (const file of parsedFiles) { const fileLength = file.length if (currentLength + fileLength <= maxLength) { - combinedString += file + codeDiff += file currentLength += fileLength } else { + maxLengthExceeded = true const remainingLength = maxLength - currentLength - combinedString += file.slice(0, remainingLength) + codeDiff += file.slice(0, remainingLength) break } } - return combinedString + return { codeDiff, maxLengthExceeded } } diff --git a/lib/replyIssueComment.ts b/lib/replyIssueComment.ts new file mode 100644 index 0000000..f4ca8cd --- /dev/null +++ b/lib/replyIssueComment.ts @@ -0,0 +1,55 @@ +import { Octokit } from "@octokit/rest" +import { ChatCompletionRequestMessage } from "openai-streams" +import { generateChatGpt } from "../utils/generateChatGpt" +import { getCodeDiff } from "../utils/getCodeDiff" + +export const startDescription = "\n\n" +export const endDescription = "" +const systemPrompt = + "You are a Git diff assistant. Given a code diff, you answer any question related to it. Be concise. Always wrap file names, functions, objects and similar in backticks (`)." + +export async function replyIssueComment(payload: any, octokit: Octokit) { + // Get relevant PR information + const { repository, issue, sender, comment } = payload + + const question = comment.body.split("/ask-codex")[1].trim() + + if (question) { + const { owner, repo, issue_number } = { + owner: repository.owner.login, + repo: repository.name, + issue_number: issue.number + } + + // Get the diff content using Octokit and GitHub API + const { codeDiff } = await getCodeDiff(owner, repo, issue_number, octokit) + + // If there are changes, trigger workflow + if (codeDiff?.length != 0) { + const messages: ChatCompletionRequestMessage[] = [ + { + role: "system", + content: `${systemPrompt}\n\nHere is the code diff:\n\n${codeDiff}` + }, + { + role: "user", + content: `${question}` + } + ] + + const codexResponse = await generateChatGpt(messages) + + const description = `> ${question}\n\n@${sender.login} ${codexResponse}` + + await octokit.issues.createComment({ + owner, + repo, + issue_number, + body: description + }) + + return codexResponse + } + throw new Error("No changes in PR") + } +} diff --git a/lib/summarizePullRequest.ts b/lib/summarizePullRequest.ts index a946643..5565ef6 100644 --- a/lib/summarizePullRequest.ts +++ b/lib/summarizePullRequest.ts @@ -1,139 +1,81 @@ import { Octokit } from "@octokit/rest" -import { ChatCompletionRequestMessage, OpenAI } from "openai-streams" -import { yieldStream } from "yield-stream" -import { parseDiff } from "../utils/parseDiff" -import { joinStringsUntilMaxLength } from "./joinStringsUntilMaxLength" +import { ChatCompletionRequestMessage } from "openai-streams" +import { generateChatGpt } from "../utils/generateChatGpt" +import { getCodeDiff } from "../utils/getCodeDiff" -export const startDescription = "" +export const startDescription = "\n\n" export const endDescription = "" +const systemPrompt = + "You are a Git diff assistant. Given a code diff, you provide a clear and concise description of its content. Always wrap file names, functions, objects and similar in backticks (`)." export async function summarizePullRequest(payload: any, octokit: Octokit) { // Get relevant PR information const pr = payload.pull_request - const { owner, repo, number } = { + const { owner, repo, pull_number } = { owner: pr.base.repo.owner.login, repo: pr.base.repo.name, - number: pr.number + pull_number: pr.number } // Get the diff content using Octokit and GitHub API - const compareResponse = await octokit.rest.repos.compareCommits({ + const { codeDiff, skippedFiles, maxLengthExceeded } = await getCodeDiff( owner, repo, - base: pr.base.sha, - head: pr.head.sha, - mediaType: { - format: "diff" - } - }) - const diffContent = String(compareResponse.data) - - // Parses the diff content and returns the parsed files. - // If the number of changes in a file is greater than 1k changes, the file will be skipped. - // The codeDiff is the joined string of parsed files, up to a max length of 10k. - const maxChanges = 1000 - const { parsedFiles, skippedFiles } = parseDiff(diffContent, maxChanges) - const codeDiff = joinStringsUntilMaxLength(parsedFiles, 10000) + pull_number, + octokit + ) // If there are changes, trigger workflow if (codeDiff?.length != 0) { - const systemPrompt = `You are a Git diff assistant. Always begin with "This PR". Given a code diff, you provide a simple description in prose, in less than 300 chars, which sums up the changes. Continue with "\n\n### Detailed summary\n" and make a comprehensive list of all changes, excluding any eventual skipped files. Be concise. Always wrap file names, functions, objects and similar in backticks (\`).${ - skippedFiles.length != 0 - ? ` After the list, conclude with "\n\n> " and mention that the following files were skipped due to too many changes: ${skippedFiles.join( - "," - )}.` - : "" - }` - const messages: ChatCompletionRequestMessage[] = [ { role: "system", - content: systemPrompt + content: `${systemPrompt}\n\nHere is the code diff:\n\n${codeDiff}` }, { role: "user", - content: `Here is the code diff:\n\n${codeDiff}` + content: + 'Starting with "This PR", clearly explain the focus of this PR in prose, in less than 300 characters. Then follow up with "\n\n### Detailed summary\n" and make a comprehensive list of all changes.' } ] - const summary = await generateChatGpt(messages) + const codexResponse = await generateChatGpt(messages) // Check if the PR already has a comment from the bot const hasCodexCommented = payload.action == "synchronize" && - pr.body?.split("\n\n" + startDescription).length > 1 - - // if (firstComment) { - // // Edit pinned bot comment to the PR - // await octokit.issues.updateComment({ - // owner, - // repo, - // comment_id: firstComment.id, - // body: summary - // }) - // } else { - // // Add a comment to the PR - // await octokit.issues.createComment({ - // owner, - // repo, - // issue_number: number, - // body: summary - // }) - // } + pr.body?.split(startDescription).length > 1 - const prCodexText = `\n\n${startDescription}\n\n---\n\n## PR-Codex overview\n${summary}\n\n${endDescription}` + const prCodexText = `${startDescription}\n\n${ + (hasCodexCommented ? pr.body.split(startDescription)[0].trim() : pr.body) + ? "---\n\n" + : "" + }## PR-Codex overview\n${codexResponse}${ + skippedFiles.length != 0 + ? `\n\n> The following files were skipped due to too many changes: ${skippedFiles.join( + ", " + )}` + : "" + }${ + maxLengthExceeded + ? "\n\n> The code diff exceeds the max number of characters, so this overview may be incomplete. Keep PRs small to avoid this issue." + : "" + }\n\n✨ Ask PR-Codex anything about this PR by commenting below with \`/ask-codex {your question}\`\n\n${endDescription}` const description = hasCodexCommented - ? pr.body.split("\n\n" + startDescription)[0] + + ? pr.body.split(startDescription)[0] + prCodexText + pr.body.split(endDescription)[1] - : pr.body + prCodexText + : (pr.body ?? "") + prCodexText await octokit.issues.update({ owner, repo, - issue_number: number, + issue_number: pull_number, body: description }) - return summary + return codexResponse } throw new Error("No changes in PR") } - -const generateChatGpt = async (messages: ChatCompletionRequestMessage[]) => { - const DECODER = new TextDecoder() - let text = "" - - try { - const stream = await OpenAI( - "chat", - { - model: "gpt-3.5-turbo", - temperature: 0.7, - messages - }, - { apiKey: process.env.OPENAI_API_KEY } - ) - - for await (const chunk of yieldStream(stream)) { - try { - const decoded: string = DECODER.decode(chunk) - - if (decoded === undefined) - throw new Error( - "No choices in response. Decoded response: " + - JSON.stringify(decoded) - ) - - text += decoded - } catch (err) { - console.error(err) - } - } - } catch (err) { - console.error(err) - } - - return text -} diff --git a/package.json b/package.json index 2d70b63..b9fbe80 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "next start", "lint": "next lint", "prettier": "prettier --write . --ignore-path .gitignore", - "summarize": "npx ts-node scripts/summarize" + "summarize": "npx ts-node scripts/summarize", + "reply": "npx ts-node scripts/reply" }, "dependencies": { "@headlessui/react": "^1.7.14", diff --git a/scripts/reply.ts b/scripts/reply.ts new file mode 100755 index 0000000..3783ee5 --- /dev/null +++ b/scripts/reply.ts @@ -0,0 +1,33 @@ +import dotenv from "dotenv" +import { handleGithubAuth } from "../lib/handleGithubAuth" +import { replyIssueComment } from "../lib/replyIssueComment" +import { testPayloadComment } from "../utils/github/testPayloadComment" + +dotenv.config() + +// Customize payload in `utils/testPayloadComment` + +async function main() { + try { + const octokit = await handleGithubAuth(testPayloadComment) + + console.log("Generating comment...") + + const comment = await replyIssueComment(testPayloadComment, octokit) + + console.log( + "PR-Codex commented:\n\n", + comment, + "\n\nView on Github: https://github.com/decentralizedlabs/pr-codex/pull/4" + ) + } catch (error) { + console.log(error) + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/scripts/summarize.ts b/scripts/summarize.ts index 7005ec4..c5c0533 100755 --- a/scripts/summarize.ts +++ b/scripts/summarize.ts @@ -1,19 +1,19 @@ import dotenv from "dotenv" import { handleGithubAuth } from "../lib/handleGithubAuth" import { summarizePullRequest } from "../lib/summarizePullRequest" -import { testPayload } from "../utils/github/testPayload" +import { testPayloadSyncPr } from "../utils/github/testPayloadSyncPr" dotenv.config() -// Customize payload in `utils/testPayload` +// Customize payload in `utils/testPayloadSyncPr` async function main() { - const octokit = await handleGithubAuth(testPayload) - try { + const octokit = await handleGithubAuth(testPayloadSyncPr) + console.log("Generating summary...") - const summary = await summarizePullRequest(testPayload, octokit) + const summary = await summarizePullRequest(testPayloadSyncPr, octokit) console.log( "PR-Codex wrote:\n\n", diff --git a/utils/generateChatGpt.ts b/utils/generateChatGpt.ts new file mode 100644 index 0000000..341f1ff --- /dev/null +++ b/utils/generateChatGpt.ts @@ -0,0 +1,41 @@ +import { ChatCompletionRequestMessage, OpenAI } from "openai-streams" +import { yieldStream } from "yield-stream" + +export const generateChatGpt = async ( + messages: ChatCompletionRequestMessage[] +) => { + const DECODER = new TextDecoder() + let text = "" + + try { + const stream = await OpenAI( + "chat", + { + model: "gpt-3.5-turbo", + temperature: 0.7, + messages + }, + { apiKey: process.env.OPENAI_API_KEY } + ) + + for await (const chunk of yieldStream(stream)) { + try { + const decoded: string = DECODER.decode(chunk) + + if (decoded === undefined) + throw new Error( + "No choices in response. Decoded response: " + + JSON.stringify(decoded) + ) + + text += decoded + } catch (err) { + console.error(err) + } + } + } catch (err) { + console.error(err) + } + + return text +} diff --git a/utils/getCodeDiff.ts b/utils/getCodeDiff.ts new file mode 100644 index 0000000..74396d7 --- /dev/null +++ b/utils/getCodeDiff.ts @@ -0,0 +1,36 @@ +import { Octokit } from "@octokit/rest" +import { joinStringsUntilMaxLength } from "../lib/joinStringsUntilMaxLength" +import { parseDiff } from "./parseDiff" + +const maxChanges = 1000 +const maxCodeDiff = 11500 + +export const getCodeDiff = async ( + owner: string, + repo: string, + pull_number: number, + octokit: Octokit +) => { + const compareResponse = await octokit.pulls.get({ + owner, + repo, + pull_number, + mediaType: { + format: "diff" + } + }) + + const diffContent = String(compareResponse.data) + + // Parses the diff content and returns the parsed files. + // If the number of changes in a file is greater than `maxChanges` changes, the file will be skipped. + // The codeDiff is the joined string of parsed files, up to a max length of `maxCodeDiff`. + const { parsedFiles, skippedFiles } = parseDiff(diffContent, maxChanges) + + const { codeDiff, maxLengthExceeded } = joinStringsUntilMaxLength( + parsedFiles, + maxCodeDiff + ) + + return { codeDiff, skippedFiles, maxLengthExceeded } +} diff --git a/utils/github/testPayload.ts b/utils/github/testPayload.ts deleted file mode 100644 index a2c753c..0000000 --- a/utils/github/testPayload.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { endDescription, startDescription } from "@lib/summarizePullRequest" - -export const testPayload = { - installation: { id: 35293807 }, - action: "synchronize", - pull_request: { - number: 4, - body: "\n\nthis is a test", - // body: - // "\n\nthis is a test\n\n" + - // startDescription + - // "\n\n---\n\n## PR-Codex overview\nThis PR adds a new file `route.ts` and makes changes to several files. It adds a homepage with a button to install the app, changes the og_image and twitter_card image formats to png, removes a navbar, adds a `Made by dlabs` line in the footer, and removes the custom connect button and signed block from the navbar.\n\n" + - // endDescription, - base: { - repo: { owner: { login: "decentralizedlabs" }, name: "pr-codex" }, - sha: "99dfdd96142d171546b59504fbba84cffe23ebd2" - }, - head: { sha: "51cfb7599b7eea7fd74ec5a0cc4c8c5c77150404" } - } -} diff --git a/utils/github/testPayloadComment.ts b/utils/github/testPayloadComment.ts new file mode 100644 index 0000000..0dfadb7 --- /dev/null +++ b/utils/github/testPayloadComment.ts @@ -0,0 +1,20 @@ +export const testPayloadComment = { + installation: { id: 35293807 }, + action: "created", + issue: { + number: 4 + }, + + comment: { + body: "/ask-codex what changes have been done in the homepage?" + }, + sender: { + login: "jjranalli" + }, + repository: { + name: "pr-codex", + owner: { + login: "decentralizedlabs" + } + } +} diff --git a/utils/github/testPayloadSyncPr.ts b/utils/github/testPayloadSyncPr.ts new file mode 100644 index 0000000..f1512cc --- /dev/null +++ b/utils/github/testPayloadSyncPr.ts @@ -0,0 +1,21 @@ +import { endDescription, startDescription } from "@lib/summarizePullRequest" + +export const testPayloadSyncPr = { + installation: { id: 35293807 }, + action: "synchronize", + pull_request: { + diff_url: "https://github.com/decentralizedlabs/pr-codex/pull/4.diff", + number: 4, + body: null, + // body: "\n\n\n\n## PR-Codex overview\nThis PR adds a new feature to the project: a GitHub app that explains and summarizes PR code diffs. It includes a new `github/route.ts` file and updates several existing files, including `README.md`, `Homepage.tsx`, `DefaultHead.tsx`, `AppLayout.tsx`, `Footer.tsx`, and `Navbar.tsx`.\n\n> The following files were skipped due to too many changes: `package-lock.json`.\n\n", + // body: "\n\nthis is a test", + // body: + // "\n\nthis is a test" + + // startDescription + + // "\n\n---\n\n## PR-Codex overview\nThis PR adds a new file `route.ts` and makes changes to several files. It adds a homepage with a button to install the app, changes the og_image and twitter_card image formats to png, removes a navbar, adds a `Made by dlabs` line in the footer, and removes the custom connect button and signed block from the navbar.\n\n" + + // endDescription, + base: { + repo: { owner: { login: "decentralizedlabs" }, name: "pr-codex" } + } + } +} diff --git a/utils/parseDiff.ts b/utils/parseDiff.ts index 9dc226a..1a011a7 100644 --- a/utils/parseDiff.ts +++ b/utils/parseDiff.ts @@ -3,18 +3,31 @@ type FileChange = { parsedFiles: string[]; skippedFiles: string[] } export function parseDiff(diff: string, maxChanges: number): FileChange { let skippedFiles: string[] = [] const files = diff.split(/diff --git /).slice(1) - const parsedFiles = files.map((file) => { + + const parsedFiles = files.flatMap((file) => { const lines = file.split("\n") + const filepath = lines[0].split(" ")[1] - const mainContent = lines.slice(4) + + // Don't consider diff in deleted files + if (lines[1].startsWith("deleted")) return `deleted ${filepath}` + + const mainContent = lines.slice(6).map((line) => { + if (line.startsWith("+") || line.startsWith("-")) { + const trimContent = line.slice(1).trim() + return line[0] + trimContent + } else return line.trim() + }) const changes = mainContent.filter( (line) => line.startsWith("+") || line.startsWith("-") ).length if (changes <= maxChanges) { - return file + return `${filepath}\n${mainContent.join("\n")}` } - skippedFiles.push(filepath.slice(2)) + skippedFiles.push(`\`${filepath.slice(2)}\``) + return [] }) + return { parsedFiles, skippedFiles } }