How I Soloed a Hackathon
Tags: opensource, openai, github, javascript
Published: 2024-07-12 13:44:35

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 <u>also earned my team a 3rd place 🥉  out of 11 competing teams at our company hackathon</u>. 

Here’s the journey of how I soloed a hackathon, splitting the tasks into manageable pieces and ultimately bringing my vision to life.

![AI Bot commenting with explanation of the file that has been changed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8s08b6gni95tnh3o8ke0.gif)

---

### 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` and `homepage URL`.
- Under `Permissions & Webhooks`, set the permissions for `Pull Requests` to `Read & 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` and `Pushes`

---

#### 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:
```bash
npm init -y
npm install express nodemon body-parser axios @octokit/app dotenv
```
- In your `package.json` modify `scripts` 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:
```javascript
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`:
```javascript
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`: 
```javascript
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:
```bash
brew install ngrok/ngrok/ngrok
```
- Start ngrok to expose your local server:
```bash
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.

1. Add helper functions to `index.js`:
```javascript
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`:
```javascript
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`:

```javascript
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