DEV Community

Cover image for How I Soloed a Hackathon
Goran Cabarkapa
Goran Cabarkapa

Posted on

How I Soloed a Hackathon

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.

AI Bot commenting with explanation of the file that has been changed


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:
npm init -y
npm install express nodemon body-parser axios @octokit/app dotenv
Enter fullscreen mode Exit fullscreen mode
  • In your package.json modify scripts to include:
"start": "nodemon index.js"
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode
  • To run the server, run this command in your terminal:
npm start
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Start ngrok to expose your local server:
ngrok http 3000
Enter fullscreen mode Exit fullscreen mode
  • 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:
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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)