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
48 changes: 48 additions & 0 deletions .github/workflows/pr_analyzer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Automated V2 PR Analyzer

on:
pull_request:
types: [opened, synchronize]

jobs:
analyze_pr:
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Set up Python Environment
uses: actions/setup-python@v5
with:
python-version: '3.10'

- name: Install System Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0

- name: Install Python Libraries
run: |
python -m pip install --upgrade pip
# Point pip to the new subfolder
pip install -r tools/ai_pr_analyzer/requirements.txt

- name: Execute V2 PR Analyzer Pipeline
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
# Grab just the integer ID of the Pull Request
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
cd tools/ai_pr_analyzer
# Pass only the number to your script
python main.py $PR_NUMBER

- name: Upload PDF Dashboard as Artifact
uses: actions/upload-artifact@v4
with:
name: BioDynaMo-PR-Analysis-Report
# Pull the generated PDF from the new path
path: tools/ai_pr_analyzer/output/*.pdf
retention-days: 14
153 changes: 153 additions & 0 deletions tools/ai_pr_analyzer/ai/llm_gemini.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@

GEMINI_API_KEY = ""
import re
import os
from google import genai
from google.genai import types

def generate_ai_insights(metrics, files_data, complexity_warnings="None"):
"""Generates PR analysis text using Gemini 2.5 Flash."""
file_list = "\n".join(list(files_data.keys()))


# THE ANALYST (Text Generation)
# ==================----------------===============---------------===========-------------
prompt_text01 = f"""
You are a Staff Software Engineer analyzing a Pull Request for BioDynaMo.

Quantitative Metrics:
- Title: {metrics.get('title', 'N/A')}
- Lines Added: {metrics.get('additions', 0)}
- Lines Removed: {metrics.get('deletions', 0)}

Files Changed:
{file_list}

High Complexity Warnings:
{complexity_warnings}

You are an expert C++ software engineer and open-source maintainer.
Analyze the following GitHub Pull Request and write a professional technical analysis report.

You must format your response exactly matching this 6-point rubric:
PURPOSE: Purpose and Functionality
POSITIVES: Positive Aspects.
CONCERNS: Potential Concerns or Weaknesses and Indicators of potential architectural issues, code smells, or spaghetti code.
CODE_QUALITY: Code Quality and Maintainability Considerations.


"""

print("Generating architectural analysis text with Gemini 2.5 Flash...")

#try:
# Initialize the official Google GenAI client
# This automatically looks for the GEMINI_API_KEY environment variable
#client = genai.Client(api_key=GEMINI_API_KEY)


#response = client.models.generate_content(
#model='gemini-2.5-flash',
#contents=prompt_text01,
#config=types.GenerateContentConfig(
#temperature=0.2, # Slight creativity for writing
# ),
#)
#text_output01 = response.text.replace("**", "").replace("## ", "")

#except Exception as e:
# print(f" Gemini API Error: {e}")
#return "Error", "Error", "Error", "Error", "Error", "Error"

prompt_text02 = f"""
You are a Staff Software Engineer analyzing a Pull Request for BioDynaMo.

Quantitative Metrics:
- Title: {metrics.get('title', 'N/A')}
- Lines Added: {metrics.get('additions', 0)}
- Lines Removed: {metrics.get('deletions', 0)}

Files Changed:
{file_list}

High Complexity Warnings:
{complexity_warnings}

You are an expert C++ software engineer and open-source maintainer.
Analyze the following GitHub Pull Request and write a professional technical analysis report.

Provide your response using EXACTLY this heading:
IMPACT: Architectural Impact Assessment, that is Assessment of the likely functional impact and breadth of the changes.
ACTION_PLAN : A prioritised list of specific recommendations for improvement.
SUMMARY : Comprehensive explanation of the contribution overall.
END: ending by

"""
prompt_text03 = f"""

You are a Staff Software Engineer analyzing a Pull Request for BioDynaMo.
Analyze the following GitHub Pull Request and based on the following GitHub Pull Request analysis, create a prioritized action plan for the developer.

Quantitative Metrics:
- Title: {metrics.get('title', 'N/A')}
- Lines Added: {metrics.get('additions', 0)}
- Lines Removed: {metrics.get('deletions', 0)}

Files Changed:
{file_list}

High Complexity Warnings:
{complexity_warnings}

Provide your response using EXACTLY this heading:
SUMMARY : Comprehensive explanation of the contribution overall.
"""

def response_out(ptext):
client = genai.Client(api_key=GEMINI_API_KEY)
response_2 = client.models.generate_content(
model='gemini-2.5-flash',
contents=ptext,)

return response_2

text_output01 = response_out(prompt_text01).text.replace("**", "").replace("## ", "")
text_output02 =response_out(prompt_text02).text.replace("**", "").replace("## ", "")
text_output03 =response_out(prompt_text03).text.replace("**", "").replace("## ", "")

print(text_output01)
print(text_output02)
#print (text_output03)

# Finally, combine them for your parser, or parse them separately!
#text_output = text_output01 + "\n" + action_plan_text


# PARSING ENGINE
# ===================------------===========---------===========---------

def extract_section(text, start_keyword, end_keywords):
end_pattern = "|".join([f"{k}:?" for k in end_keywords]) + "|$"
pattern = rf"{start_keyword}:?\s*(.*?)(?={end_pattern})"
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
return match.group(1).strip() if match else f"Data not generated for {start_keyword}."

try:
purpose = extract_section(text_output01, "PURPOSE: Purpose and Functionality", ["POSITIVES: Positive Aspects", "CONCERNS: Potential Concerns or Weaknesses and Indicators of potential architectural issues, code smells, or spaghetti code", "CODE_QUALITY: Code Quality and Maintainability Considerations", "IMPACT: Architectural Impact Assessment, that is Assessment of the likely functional impact and breadth of the changes"])
positives = extract_section(text_output01, "POSITIVES: Positive Aspects", ["CONCERNS: Potential Concerns or Weaknesses and Indicators of potential architectural issues, code smells, or spaghetti code", "CODE_QUALITY: Code Quality and Maintainability Considerations", "IMPACT: Architectural Impact Assessment, that is Assessment of the likely functional impact and breadth of the changes"])
concerns = extract_section(text_output01, "CONCERNS: Potential Concerns or Weaknesses and Indicators of potential architectural issues, code smells, or spaghetti code", ["CODE_QUALITY: Code Quality and Maintainability Considerations", "IMPACT: Architectural Impact Assessment, that is Assessment of the likely functional impact and breadth of the changes"])
quality = extract_section(text_output01, "CODE_QUALITY: Code Quality and Maintainability Considerations", ["IMPACT: Architectural Impact Assessment, that is Assessment of the likely functional impact and breadth of the changes"])
impact = extract_section(text_output02, "IMPACT: Architectural Impact Assessment, that is Assessment of the likely functional impact and breadth of the changes",["ACTION_PLAN : A prioritised list of specific recommendations for improvement","SUMMARY : Comprehensive explanation of the contribution overall","END: ending by"])
action_plan = extract_section(text_output02, "ACTION_PLAN : A prioritised list of specific recommendations for improvement",["SUMMARY : Comprehensive explanation of the contribution overall","END: ending by"])
summary = extract_section(text_output02, "SUMMARY : Comprehensive explanation of the contribution overall",["END: ending by"])
print(summary)

except Exception as e:
print(f">*< >*< >*< >*< >*< >*< >*< Parsing Error: {e}")
return "Error", "Error", "Error", "Error", "Error", "Error"


return purpose, positives, concerns, quality, impact, action_plan, summary


#return text_output01, ap_text_output
50 changes: 50 additions & 0 deletions tools/ai_pr_analyzer/analysis/static_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import os
import base64
import lizard
import matplotlib.pyplot as plt


def generate_complexity_chart(file_data):
#calculates cyclomatric complexity using lizard and output a Base64 PNG

filenames, complexities = [], []
for filename, code_string in file_data.items():
#feeding raw c++ code into lizard, so it can scan and find the code complexity.
analysis = lizard.analyze_file.analyze_source_code(filename,code_string)
# we are complexity of the by taking avg code complexty as the sum of number of cyclomatic_complexty by number of functions.
avg_ccn = sum(f.cyclomatic_complexity for f in analysis.function_list) / len(analysis.function_list) if analysis.function_list else 0
filenames.append(filename.split('/')[-1])
complexities.append(avg_ccn)

if not filenames:
return None

#plotting graph for file name vs code complexity

plt.figure(figsize=(8,4))
colors = ['#e74c34' if c > 10 else '#f39c12' if c > 5 else '#27ae60' for c in complexities]
plt.bar(filenames, complexities, color = colors)
plt.axhline( y= 10, color='r', linestyle ='--', label='High Risk (CCN > 10)')
plt.title('Code Complexities Impact by File')
plt.ylabel('Avg Clyclomatric Complexity')
plt.xticks(rotation =45, ha= 'right')
plt.tight_layout()

chart_path = 'temp_complexity.png'
plt.savefig(chart_path, dpi =150)
plt.close()

# reopening sved plot as read binary and encodes it into standard base64 string , so that it can be weasyprint(it can read base64 sting in html)
#can save local memory
with open(chart_path, 'rb') as f:
chart_b64 = base64.b64encode(f.read()).decode('utf-8')
os.remove(chart_path)
return chart_b64








56 changes: 56 additions & 0 deletions tools/ai_pr_analyzer/api/github_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import os
import requests


GEMINI_API_KEY =""
GITHUB_TOKEN=""

#def extract_pr_details(url):

def get_pr_data(owner, repo, pr_number):

#fetching quantiataive metrics and changed file contents from the PR
GITHUB_TOKEN= os.getenv("")
headers ={"Accept": "application/vnd.github.v3+json"}

if GITHUB_TOKEN:
headers["Authorization"] = f"token {GITHUB_TOKEN}"
else:
print("Warning: No GitHub Token found. Proceeding with anonymous API limits.")

# Get PR meta and metrics

pr_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
pr_res = requests.get(pr_url,headers=headers)
pr_res.raise_for_status()
pr_info = pr_res.json()

# extrating informations
metrics ={
"additions": pr_info.get("additions", 0),
"deletions": pr_info.get("deletions",0),
"changed_files": pr_info.get("changed_files"),
"total_lines": pr_info.get("additions",0) + pr_info.get("deletions",0),
"title": pr_info.get("title",""),
"description": pr_info.get("body","")

}

#get changed files for statistical analysis

files_url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files"
files_res = requests.get(files_url,headers=headers)
files_res.raise_for_status()

files_data = {}
for f in files_res.json():
filename = f["filename"]
if filename.endswith(('.cpp','.cc','.h','.hpp'))and f['status'] != 'removed':
raw_url = f["raw_url"]
raw_res = requests.get(raw_url, headers=headers)
if raw_res.status_code == 200:
files_data[filename] =raw_res.text

return metrics, files_data


42 changes: 42 additions & 0 deletions tools/ai_pr_analyzer/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import sys
from dotenv import load_dotenv

# Load environment variables before doing anything else
load_dotenv()

# Import our custom modules
from api.github_client import get_pr_data
from analysis.static_analyzer import generate_complexity_chart
from ai.llm_gemini import generate_ai_insights
from reporting.pdf_02_generator import generate_pdf_report

if __name__ == "__main__":
OWNER = "BioDynaMo"
REPO = "biodynamo"
PR_NUMBER = 471

if len(sys.argv) > 1:
# sys.argv[1] is the number we passed from the YAML file
PR_NUMBER = int(sys.argv[1])
print(f"CI/CD Trigger Detected! Targeting PR #{PR_NUMBER}")

print(f"Fetching data for {OWNER}/{REPO} PR #{PR_NUMBER}...")
metrics, files_data = get_pr_data(OWNER, REPO, PR_NUMBER)

print("*-----> Running static C++ complexity analysis...")
chart_b64 = generate_complexity_chart(files_data)


# Phase 3: AI Brain
print("*---> Phase 3: Generating AI insights...")
purpose, positives, concerns, quality, impact, action_plan, summary = generate_ai_insights(metrics, files_data)

# Phase 4: Flowchart Rendering
#print("🎨 Phase 4: Rendering architecture diagrams...")
#mermaid_svg = render_mermaid_to_svg(mermaid_code)


# Phase 5: PDF Assembly
print("*----> Phase 5: Compiling final PDF report...")
output_filename = f"PR_{PR_NUMBER}_Analysis.pdf"
generate_pdf_report(metrics, purpose, positives, concerns, quality, impact, action_plan, summary, PR_NUMBER, chart_b64, output_filename)
Loading