It's Friday, the room is buzzing with anticipation, and the clock is ticking. Armed only with a laptop and an overcharged brain, I am supposed to step out of my comfort zone and into the battleground of a hackathon. With 12 hours on the clock, I had an idea but no clear sense of its feasibility within the timeframe. The goal?
To create a tool that leverages OpenAI to automatically provide human-readable explanations of the files changed within a pull request, making the review process accessible to everyone on the team—both those well-versed in code and those who are not.
This was no ordinary challenge; it was a coding quest, where each step presented a new obstacle, a new monster to defeat. And defeat them I did, one by one, getting closer to the finish line with each victory. As the session drew to a close, the euphoria and dopamine from being so focused and in the zone were palpable. The greatest win and takeaway?
Realizing that I can come on top, no matter the challenge, and by being pushed out of my comfort zone I can learn so much.
By the end of the intense nine-hour session, not only did I have a working prototype, but it also earned my team a 3rd place 🥉 out of 11 competing teams at our company hackathon.
Here’s the journey of how I soloed a hackathon, splitting the tasks into manageable pieces and ultimately bringing my vision to life.
Step-by-Step Implementation Guide
In this guide, we'll walk through the step-by-step implementation of this tool. We'll start with setting up Github App, Github repo webhook and a basic server and gradually build up to the final solution, adding functionality at each step. Buckle up!
Step 1: Setting Up Your Github App
First, create a GitHub App from your GitHub account or organization:
Go to GitHub, under your profile >
Settings
>Developer settings
>GitHub Apps
.Click
New GitHub App
.Fill in only the required details, such as the
App name
andhomepage URL
.Under
Permissions & Webhooks
, set the permissions forPull Requests
toRead & Write
.Generate a
private key
and download it. Save this private key in an easily accessible location, as you'll need it later in your code.Under
Install App
choose the account which is the owner of the repo you want this app to be installed on and configure it so that you allow access for that repo.
NOTE: Save APP ID, you will need it for environment variable later on.
Step 2: Setting up your Webhook
Go to your Github repo >
Settings
>Webhooks
>Add webhook
Payload URL:
http://your-ngrok-url/webhook
(we'll set up ngrok later)Content type:
application/json
Events: Subscribe to
Pull requests
andPushes
Step 3: Setting Up the Server
First, we need to set up a basic Express
server. This server will eventually handle incoming webhook
events from GitHub.
- Initialize your project:
npm init -y
npm install express nodemon body-parser axios @octokit/app dotenv
- In your
package.json
modifyscripts
to include:
"start": "nodemon index.js"
This will allow for automatic refreshes, whenever you make a change to your index.js
file.
- Create an
index.js
file and add the initial server setup code:
import express from "express";
import bodyParser from "body-parser";
import dotenv from "dotenv";
dotenv.config(); // Load environment variables
const app = express();
const port = 3000;
app.use(bodyParser.json());
app.get("/", (req, res) => {
res.send("You are running an AI Bot PR Code Explainer");
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
- To run the server, run this command in your terminal:
npm start
This basic server will respond with a simple message when accessed at the root URL.
Step 4: Adding GitHub App Authentication
Next, we'll add authentication for the GitHub App. We'll use the @octokit/app
package to create an instance of the GitHub App.
- Add the necessary imports and setup for the GitHub App in
index.js
:
import { App } from "@octokit/app";
import fs from "fs";
// Read the private key file
const privateKey = fs.readFileSync(
`${process.env.HOME}/<directory_name>/<name_of_your_key>.pem`,
"utf8"
);
const githubApp = new App({
appId: process.env.GITHUB_APP_ID, // Your GitHub App ID
privateKey: privateKey, // The private key content
});
const installationId = process.env.GITHUB_APP_INSTALLATION_ID; // Your installation ID
This code reads the private key file and sets up the GitHub App using the App ID and installation ID from the environment variables.
NOTE: You can obtain installation ID
if you go to your Github's repo Settings
and check the Integrations
section at the bottom. You should be able to see on the list of integred apps, the app that you've authorized in Step 1.
Click on Configure
and your URL will look something like this: https://github.com/settings/installations/52601487
where 52601487
is the installation id.
Step 5: Handling GitHub Webhooks
Now, let's add the functionality to handle GitHub webhooks. We'll create an endpoint to receive webhook events and process pull request events.
- Add the webhook handler to
index.js
:
app.post("/webhook", async (req, res) => {
const event = req.headers["x-github-event"];
// Simulate event handling
if (event === "pull_request") {
const action = req.body.action;
if ((action === "opened") | (action === "synchronize")) {
await handlePullRequest({ payload: req.body });
}
}
res.status(200).send("Webhook received");
});
async function handlePullRequest({ payload }) {
try {
const action = payload.action;
const pr = payload.pull_request;
if (pr && (action === "opened" || action === "synchronize")) {
const owner = pr.base.repo.owner.login;
const repo = pr.base.repo.name;
const prNumber = pr.number;
// Get the Octokit instance for the specific installation
const octokit = await githubApp.getInstallationOctokit(installationId);
if (!octokit) {
throw new Error("Failed to obtain Octokit instance");
}
const headCommitSha = pr.head.sha; // Get the latest commit SHA
const baseCommitSha = await getBaseCommitSha(
octokit,
owner,
repo,
headCommitSha
); // Get the base commit SHA for comparison
const diffData = await octokit.request(
`GET /repos/${owner}/${repo}/compare/${baseCommitSha}...${headCommitSha}`
); // Compare the base and head commits to get the diff
const parsedDiff = parseDiff(diffData.data); // Parse the diff to get the list of changed files
const filteredDiff = filterIgnoredFiles(parsedDiff); // Filter out ignored files
const fileChanges = await fetchFileContents(
octokit,
owner,
repo,
filteredDiff,
headCommitSha
); // Fetch the content of each changed file
const { comments, removedFiles } = await generateReviewComments(
fileChanges,
headCommitSha
); // Generate review comments for the changed files
// Ensure no duplicate comments
const uniqueComments = Array.from(
new Set(comments.map((c) => JSON.stringify(c)))
).map((str) => JSON.parse(str));
const existingComments = await fetchExistingComments(
octokit,
owner,
repo,
prNumber
); // Fetch existing comments on the pull request
await handleRemovedFiles(
octokit,
owner,
repo,
existingComments,
removedFiles
); // Delete comments for files that have been removed
await postNewComments(
octokit,
owner,
repo,
prNumber,
existingComments,
uniqueComments
); // Post new comments for added and modified files
}
} catch (error) {
console.error("Error processing pull request:", error);
}
}
This code sets up a webhook endpoint and defines a handler for pull request events. When a pull request is opened
or synchronized
, the handlePullRequest
function is called.
Step 6: Exposing Localhost with ngrok
To test your webhook handler locally, you need to expose your localhost server to the internet. We'll use ngrok
for this purpose.
- Install ngrok:
brew install ngrok/ngrok/ngrok
- Start ngrok to expose your local server:
ngrok http 3000
- Copy the public URL provided by ngrok and set it as the Webhook's
Payload URL
in your Github repo settings.
Step 7: Fetching Pull Request Data
In this step, we'll add functions to fetch pull request data, including the list of changed files and their contents.
- Add helper functions to
index.js
:
async function getBaseCommitSha(octokit, owner, repo, headSha) {
const { data: commits } = await octokit.request(
`GET /repos/${owner}/${repo}/commits`,
{
sha: headSha,
per_page: 2,
}
);
// If there are more than one commit, return the SHA of the second one (base)
if (commits.length > 1) {
return commits[1].sha;
}
// If there's only one commit, return the head SHA
return headSha;
}
function parseDiff(diff) {
const files = diff.files;
return files.map((file) => {
const { filename, status, previous_filename } = file;
return { fileName: filename, status, oldFileName: previous_filename };
});
}
function filterIgnoredFiles(parsedDiff) {
const ignoredFiles = ["package.json", "package-lock.json"];
return parsedDiff.filter((file) => !ignoredFiles.includes(file.fileName));
}
async function fetchFileContents(octokit, owner, repo, parsedDiff, commitId) {
return await Promise.all(
parsedDiff.map(async (file) => {
try {
const fileContent = await getFileContent(
octokit,
owner,
repo,
file.fileName,
commitId
);
return { ...file, fileContent };
} catch (error) {
if (error.status === 404) {
return { ...file, fileContent: null };
} else {
throw error;
}
}
})
).then((results) => results.filter((file) => file !== null)); // Filter out null values
}
async function getFileContent(octokit, owner, repo, path, commitId) {
const result = await octokit.request(
`GET /repos/${owner}/${repo}/contents/${path}`,
{
ref: commitId, // Specify the commit SHA as the reference
}
);
const content = Buffer.from(result.data.content, "base64").toString("utf-8");
return content;
}
These functions help fetch the base commit SHA, parse the diff data to get the list of changed files, filter out ignored files, and fetch the content of each changed file.
Step 8: Integrating OpenAI
Now, we'll add the functionality to generate human-readable explanations of the code changes using OpenAI.
- Add the OpenAI integration to
index.js
:
async function generateReviewComments(fileChanges, commitId) {
const comments = [];
const removedFiles = [];
const prefix = "This comment was generated by AI Bot:\n\n";
for (const { fileName, status, fileContent, oldFileName } of fileChanges) {
let explanation = "";
if (status === "added" || status === "modified") {
explanation = await getChatCompletion(fileContent);
comments.push({
path: fileName,
body: prefix + explanation,
commit_id: commitId,
});
} else if (status === "removed") {
removedFiles.push(fileName);
} else if (status === "renamed") {
explanation = await getChatCompletion(fileContent);
comments.push({
path: fileName,
body: prefix + explanation,
commit_id: commitId,
});
removedFiles.push(oldFileName);
}
}
return { comments, removedFiles };
}
async function getChatCompletion(fileContent) {
const messages = [
{
role: "system",
content:
"You are a Javascript expert. Give explanation in 4 or less short sentences.",
},
{
role: "user",
content: `Here's a file with JavaScript code:\n\n${fileContent}\n\n${"Please provide an overview of this file."}`,
},
];
try {
const response = await axios.post(
"https://api.openai.com/v1/chat/completions",
{
model: "gpt-3.5-turbo",
messages,
temperature: 0.4,
max_tokens: 3896,
},
{
headers: {
Authorization: `Bearer ${openaiApiKey}`,
"Content-Type": "application/json",
},
}
);
return response.data.choices[0].message.content.trim();
} catch (error) {
console.error("Error getting chat completion:", error);
throw error;
}
}
These functions generate explanations for the code changes using OpenAI and format them for posting as comments.
Step 9: Posting Comments on GitHub
Finally, we'll add the functionality to post the AI-generated comments back to the pull request on GitHub.
- Add functions to handle existing comments and post new comments in
index.js
:
async function fetchExistingComments(octokit, owner, repo, pullNumber) {
const existingComments = await octokit.request(
`GET /repos/${owner}/${repo}/pulls/${pullNumber}/comments`
);
return existingComments.data;
}
async function handleRemovedFiles(
octokit,
owner,
repo,
existingComments,
removedFiles
) {
for (const fileName of removedFiles) {
const existingComment = existingComments.find(
(c) =>
c.path === fileName &&
c.body.startsWith("This comment was generated by AI Bot:")
);
if (existingComment) {
await octokit.request(
"DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}",
{
owner,
repo,
comment_id: existingComment.id,
}
);
}
}
}
async function postNewComments(
octokit,
owner,
repo,
pullNumber,
existingComments,
comments
) {
for (const comment of comments) {
// Check if there is an existing comment for this path
const existingComment = existingComments.find(
(c) =>
c.path === comment.path &&
c.body.startsWith("This comment was generated by AI Bot:")
);
if (existingComment) {
// Delete the existing comment
try {
await octokit.request(
"DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}",
{
owner,
repo,
comment_id: existingComment.id,
}
);
} catch (error) {
console.error("Error deleting comment:", error);
}
}
// Post the new comment
try {
await octokit.request(
`POST /repos/{owner}/{repo}/pulls/{pull_number}/comments`,
{
owner,
repo,
pull_number: pullNumber,
body: comment.body,
path: comment.path,
commit_id: comment.commit_id,
subject_type: "file",
}
);
} catch (error) {
console.error("Error posting comment:", error);
}
}
}
These functions fetch existing comments on the pull request, handle comments for removed files, and post new comments for added or modified files.
Conclusion
By breaking down the project into smaller tasks and leveraging powerful APIs like GitHub and OpenAI, I was able to create a functional and helpful tool within a limited time frame. Now, I’m excited to open-source this project as a minimal viable product (MVP). While it's not production-ready, with few minor tweaks here and there it could be! As of now, it can serve as a template to jump-start the development of similar AI review bots. Happy hacking!
Resources
Github App - ChadReviewer - https://github.com/apps/chadreviewer
Source code - https://github.com/rangoc/ai-bot-pr-code-explainer
Top comments (0)