diff --git a/.github/workflows/pr_analyzer.yml b/.github/workflows/pr_analyzer.yml new file mode 100644 index 000000000..914a6d386 --- /dev/null +++ b/.github/workflows/pr_analyzer.yml @@ -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 \ No newline at end of file diff --git a/tools/ai_pr_analyzer/ai/llm_gemini.py b/tools/ai_pr_analyzer/ai/llm_gemini.py new file mode 100644 index 000000000..b429cf517 --- /dev/null +++ b/tools/ai_pr_analyzer/ai/llm_gemini.py @@ -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 \ No newline at end of file diff --git a/tools/ai_pr_analyzer/analysis/static_analyzer.py b/tools/ai_pr_analyzer/analysis/static_analyzer.py new file mode 100644 index 000000000..fbc48caed --- /dev/null +++ b/tools/ai_pr_analyzer/analysis/static_analyzer.py @@ -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 + + + + + + + + diff --git a/tools/ai_pr_analyzer/api/github_client.py b/tools/ai_pr_analyzer/api/github_client.py new file mode 100644 index 000000000..08e8cc8af --- /dev/null +++ b/tools/ai_pr_analyzer/api/github_client.py @@ -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 + + \ No newline at end of file diff --git a/tools/ai_pr_analyzer/main.py b/tools/ai_pr_analyzer/main.py new file mode 100644 index 000000000..eba92562a --- /dev/null +++ b/tools/ai_pr_analyzer/main.py @@ -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) \ No newline at end of file diff --git a/tools/ai_pr_analyzer/reporting/pdf_02_generator.py b/tools/ai_pr_analyzer/reporting/pdf_02_generator.py new file mode 100644 index 000000000..42624a23f --- /dev/null +++ b/tools/ai_pr_analyzer/reporting/pdf_02_generator.py @@ -0,0 +1,94 @@ +import zlib +import base64 +import markdown +import requests +from weasyprint import HTML + + + +def generate_pdf_report(metrics, purpose, positives, concerns, quality, impact, action_plan, summary, pr_number, chart_b64, output_filename): + """Compiles the HTML template and renders the final PDF.""" + + # 1. Convert Markdown to raw HTML + #raw_html01 = markdown.markdown(text_output01, extensions=['extra', 'codehilite']) + #raw_html02 =markdown.markdown(ap_text_output, extensions=['extra', 'codehilite']) + purpose_p = markdown.markdown(purpose, extensions=['extra', 'codehilite']) + positives_p = markdown.markdown(positives, extensions=['extra', 'codehilite']) + concerns_c = markdown.markdown(concerns, extensions=['extra', 'codehilite']) + quality_q = markdown.markdown(quality, extensions=['extra', 'codehilite']) + impact_i = markdown.markdown(impact, extensions=['extra', 'codehilite']) + action_plan_ap = markdown.markdown(action_plan, extensions=['extra', 'codehilite']) + summary_s = markdown.markdown(summary, extensions=['extra', 'codehilite']) + + + + + total_size = metrics.get('additions', 0) + metrics.get('deletions', 0) + files_changed = metrics.get('changed_files', len(metrics.get('file_names', []))) + + html_content = f""" + + +
+ + + +PR Title: {metrics.get('title', 'N/A')}
+ +{purpose_p}
Files exceeding an average complexity of 10 should be reviewed for potential refactoring.
+{positives_p}
{concerns_c}
{quality_q}
{impact_i}
{action_plan_ap}
{summary_s}