Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate

db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
235 changes: 23 additions & 212 deletions src/github/app.py
Original file line number Diff line number Diff line change
@@ -1,226 +1,37 @@
from flask import Flask
from dotenv import load_dotenv
import logging
import os
import re
from dotenv import load_dotenv
from flask import Flask, request
import requests

from github_app import get_installation_token
from preferences import extract_and_save_preference
from review import review_pr, review_comment

load_dotenv()
app = Flask(__name__)

logging.basicConfig(level=logging.INFO)

WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET")
BOT_NAME = os.getenv("BOT_NAME")


@app.post("/webhook")
def webhook():
event = request.headers.get("X-GitHub-Event")
body = request.json

# Ignore comments made by the bot itself to prevent loops
sender = body.get("sender", {}).get("login")
if sender and sender.lower() == BOT_NAME.lower():
logging.info(f"Ignoring event triggered by the bot itself ({sender}).")
return "Ignoring own event"

if event == "issue_comment" and body["action"] == "created":
comment_body = body["comment"]["body"].strip()
pr_number = body["issue"]["number"]
owner = body["repository"]["owner"]["login"]
repo = body["repository"]["name"]
installation_id = body["installation"]["id"]
token = get_installation_token(installation_id)

if comment_body == "/review":
handle_review_command(owner, repo, pr_number, token)
elif f"@{BOT_NAME.lower()}" in comment_body.lower():
handle_issue_comment(owner, repo, pr_number, comment_body, token)

elif event == "pull_request_review_comment" and body["action"] == "created":
if f"@{BOT_NAME.lower()}" in body["comment"]["body"].lower():
installation_id = body["installation"]["id"]
token = get_installation_token(installation_id)
owner = body["repository"]["owner"]["login"]
repo = body["repository"]["name"]
pr_number = body["pull_request"]["number"]
comment_body = body["comment"]["body"]
comment_id = body["comment"]["id"]
diff_hunk = body["comment"]["diff_hunk"]
handle_review_comment(
owner, repo, pr_number, comment_body, comment_id, token, diff_hunk
)

return "Request received"


def handle_review_command(owner, repo, pr_number, token):
"""
Handles a command to trigger a full PR review.
"""
headers = {"Authorization": f"token {token}"}

logging.info(f"Handling /review command for {owner}/{repo} PR #{pr_number}")

try:
# Get PR data to find the head SHA, title, and body
pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
pr_response = requests.get(pr_url, headers=headers)
pr_response.raise_for_status() # Check for errors
pr_data = pr_response.json()

pr_body = pr_data.get("body", "")
pr_title = pr_data.get("title", "")
commit_id = pr_data.get("head", {}).get("sha")

if not commit_id:
logging.error(f"Could not find head SHA for PR #{pr_number}")
return

# This is the existing function that does the review
send_review(owner, repo, pr_number, pr_body, pr_title, token, commit_id)
logging.info(f"Successfully triggered review for PR #{pr_number}")

except requests.exceptions.RequestException as e:
logging.error(f"Failed to fetch PR data for review. Error: {e}")
logging.error(f"Response body: {e.response.text if e.response else 'No response'}")


def handle_issue_comment(owner, repo, pr_number, comment_body, token):
if f"@{BOT_NAME.lower()}" not in comment_body.lower():
logging.warning("handle_issue_comment called without a bot mention. Skipping.")
return

headers = {"Authorization": f"token {token}"}

logging.info(f"Handling issue comment for {owner}/{repo} PR #{pr_number}")

preference_response = extract_and_save_preference(comment_body)
review_response = review_comment(comment_body)

if preference_response:
response_body = f"{preference_response}\n\n---\n\n{review_response}"
else:
response_body = review_response

comment_url = (
f"https://api.github.com/repos/{owner}/{repo}/issues/{pr_number}/comments"
)

try:
api_response = requests.post(comment_url, headers=headers, json={"body": response_body})
api_response.raise_for_status() # Raise an exception for bad status codes
logging.info(f"Successfully posted comment to PR #{pr_number}. Status: {api_response.status_code}")
except requests.exceptions.RequestException as e:
logging.error(f"Failed to post comment to PR #{pr_number}. Error: {e}")
logging.error(f"Response body: {e.response.text if e.response else 'No response'}")


def handle_review_comment(
owner, repo, pr_number, comment_body, comment_id, token, diff_hunk
):
if f"@{BOT_NAME.lower()}" not in comment_body.lower():
logging.warning("handle_review_comment called without a bot mention. Skipping.")
return

headers = {"Authorization": f"token {token}"}

logging.info(f"Handling review comment for {owner}/{repo} PR #{pr_number}")

preference_response = extract_and_save_preference(comment_body)
review_response = review_comment(comment_body, diff_hunk)

if preference_response:
response_body = f"{preference_response}\n\n---\n\n{review_response}"
else:
response_body = review_response

comment_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments/{comment_id}/replies"

try:
api_response = requests.post(comment_url, headers=headers, json={"body": response_body})
api_response.raise_for_status()
logging.info(f"Successfully posted reply to comment {comment_id} in PR #{pr_number}. Status: {api_response.status_code}")
except requests.exceptions.RequestException as e:
logging.error(f"Failed to post reply to comment {comment_id} in PR #{pr_number}. Error: {e}")
logging.error(f"Response body: {e.response.text if e.response else 'No response'}")


def send_review(owner, repo, pr_number, pr_body, pr_title, token, commit_id):
headers = {"Authorization": f"token {token}"}

# Fetch files from the pull request
files_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files"
files = requests.get(files_url, headers=headers).json()

try:
with open("../../data/preferences.md", "r") as f:
preferences = f.read()
except FileNotFoundError:
preferences = ""

# Generate summary of changes
summary = summarize_changes(files, pr_body, pr_title, preferences)

# Post review comments
post_review_comments(owner, repo, pr_number, commit_id, summary, headers)
from ..extensions import db, login_manager, migrate
from ..models import User

def create_app():
load_dotenv()
app = Flask(__name__)

def post_review_comments(owner, repo, pr_number, commit_id, summary, headers):
comments = parse_review_comments(summary)
for comment in comments:
comment_url = (
f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/comments"
)
payload = {
"body": comment["comment"],
"commit_id": commit_id,
"path": comment["file"],
"line": comment["line"],
}
requests.post(comment_url, headers=headers, json=payload)
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'a-very-secret-key')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///../instance/site.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

logging.basicConfig(level=logging.INFO)

def parse_review_comments(summary):
"""
Parses the review summary to extract individual comments.
"""
# The regex looks for a pattern like: `* **app.py:42** - The comment text.`
comment_pattern = re.compile(r"\*\s*\*\*(.+?):(\d+)\*\* - (.+)")
comments = []
for line in summary.splitlines():
match = comment_pattern.match(line)
if match:
comments.append(
{
"file": match.group(1),
"line": int(match.group(2)),
"comment": match.group(3),
}
)
return comments
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
migrate.init_app(app, db, render_as_batch=True)
login_manager.login_view = 'site.login'


def summarize_changes(files, pr_body, pr_title, preferences):
"""
Generates a summary of the changes in a pull request.
"""
keys_to_remove = ["sha", "blob_url", "raw_url", "contents_url"]
for file in files:
for key in keys_to_remove:
file.pop(key, None)
# Blueprints
from .webhook import webhook as webhook_blueprint
app.register_blueprint(webhook_blueprint)

review_prompt = ""
for file in files:
review_prompt += f"\nFile Name: \n{file['filename']} \nStatus: {file['status']}\nAdditions: {file['additions']}\nDeletions: {file['deletions']}\nChanges: {file['changes']}\nDiff: {file['patch']}"
summary = review_pr(review_prompt, pr_body, pr_title, preferences)
return summary
from ..site.website import site as site_blueprint
app.register_blueprint(site_blueprint)

return app

if __name__ == "__main__":
app = create_app()
app.run(port=3000, debug=True)
7 changes: 6 additions & 1 deletion src/github/github_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
load_dotenv()

APP_ID = os.getenv("APP_ID")
PRIVATE_KEY = open("../../hack-review.pem").read()
# Construct the path to the private key file relative to this script
dir_path = os.path.dirname(os.path.realpath(__file__))
private_key_path = os.path.join(dir_path, "..", "..", "hack-review.pem")

with open(private_key_path, "r") as f:
PRIVATE_KEY = f.read()

def generate_jwt():
payload = {
Expand Down
13 changes: 11 additions & 2 deletions src/github/preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
API_KEY = os.getenv("API_KEY")
MODEL = os.getenv("MODEL")

client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1")
if API_KEY:
client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1")
else:
client = None

def extract_and_save_preference(comment_body):
"""
Analyzes a comment to see if it contains a project preference, and if so,
saves it to the data/preferences.md file.
"""
if not client:
return ""

try:
system_prompt = """You are an AI assistant tasked with identifying project-specific preferences from user comments.
Analyze the following comment. If it contains a clear preference, convention, or rule for the project's codebase, please summarize it in a single, concise sentence.
Expand All @@ -34,7 +40,10 @@ def extract_and_save_preference(comment_body):
preference = response.choices[0].message.content.strip()

if "NO_PREFERENCE" not in preference:
with open("../../data/preferences.md", "a") as f:
# Construct path relative to the project root
dir_path = os.path.dirname(os.path.realpath(__file__))
preferences_path = os.path.join(dir_path, "..", "..", "data", "preferences.md")
with open(preferences_path, "a") as f:
f.write(f"- {preference}\n")
return f"Preference noted: {preference}"
else:
Expand Down
15 changes: 13 additions & 2 deletions src/github/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@
API_KEY = os.getenv("API_KEY")
MODEL = os.getenv("MODEL")

client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1")
if API_KEY:
client = OpenAI(api_key=API_KEY, base_url="https://ai.hackclub.com/proxy/v1")
else:
client = None


def review_pr(review_prompt, pr_body, pr_title, preferences=""):
if not client:
return "The API_KEY is not configured."
try:
with open("../../data/System_Prompt.md", "r") as system_prompt_file:
dir_path = os.path.dirname(os.path.realpath(__file__))
system_prompt_path = os.path.join(dir_path, "..", "..", "data", "System_Prompt.md")
with open(system_prompt_path, "r") as system_prompt_file:
system_prompt = system_prompt_file.read()
except FileNotFoundError:
print("Error: The file 'data/System_Prompt.md' was not found.")
Expand All @@ -29,6 +36,8 @@ def review_pr(review_prompt, pr_body, pr_title, preferences=""):


def review_comment(comment_body, diff_hunk=None):
if not client:
return "The API_KEY is not configured."
system_prompt = "You are a helpful AI assistant. A user has mentioned you in a code review comment, please respond to them."
if diff_hunk:
user_prompt = f"The user commented:\n{comment_body}\n\nThe code they are commenting on is:\n{diff_hunk}"
Expand All @@ -38,6 +47,8 @@ def review_comment(comment_body, diff_hunk=None):


def generate_review(user_prompt, system_prompt):
if not client:
return "The API_KEY is not configured."
try:
response = client.chat.completions.create(
model=MODEL,
Expand Down
Loading