gitflow-analytics 3.12.6__py3-none-any.whl → 3.13.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. gitflow_analytics/_version.py +1 -1
  2. gitflow_analytics/cli.py +853 -129
  3. gitflow_analytics/cli_wizards/__init__.py +9 -3
  4. gitflow_analytics/cli_wizards/menu.py +798 -0
  5. gitflow_analytics/config/loader.py +3 -1
  6. gitflow_analytics/config/profiles.py +1 -2
  7. gitflow_analytics/core/data_fetcher.py +0 -2
  8. gitflow_analytics/core/identity.py +2 -0
  9. gitflow_analytics/extractors/tickets.py +3 -1
  10. gitflow_analytics/integrations/github_integration.py +1 -1
  11. gitflow_analytics/integrations/jira_integration.py +1 -1
  12. gitflow_analytics/qualitative/chatgpt_analyzer.py +15 -15
  13. gitflow_analytics/qualitative/classifiers/llm/prompts.py +1 -1
  14. gitflow_analytics/qualitative/core/processor.py +1 -2
  15. gitflow_analytics/qualitative/enhanced_analyzer.py +24 -8
  16. gitflow_analytics/reports/narrative_writer.py +13 -9
  17. gitflow_analytics/security/reports/__init__.py +5 -0
  18. gitflow_analytics/security/reports/security_report.py +358 -0
  19. gitflow_analytics/ui/progress_display.py +14 -6
  20. gitflow_analytics/verify_activity.py +1 -1
  21. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/METADATA +37 -1
  22. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/RECORD +26 -23
  23. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/WHEEL +0 -0
  24. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/entry_points.txt +0 -0
  25. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/licenses/LICENSE +0 -0
  26. {gitflow_analytics-3.12.6.dist-info → gitflow_analytics-3.13.5.dist-info}/top_level.txt +0 -0
@@ -968,7 +968,9 @@ class ConfigLoader:
968
968
  (
969
969
  cls._resolve_env_var(item)
970
970
  if isinstance(item, str)
971
- else cls._resolve_config_dict(item) if isinstance(item, dict) else item
971
+ else cls._resolve_config_dict(item)
972
+ if isinstance(item, dict)
973
+ else item
972
974
  )
973
975
  for item in value
974
976
  ]
@@ -234,8 +234,7 @@ class ProfileManager:
234
234
  if not profile_class:
235
235
  available = ", ".join(cls._profiles.keys())
236
236
  raise ValueError(
237
- f"Unknown configuration profile: {profile_name}. "
238
- f"Available profiles: {available}"
237
+ f"Unknown configuration profile: {profile_name}. Available profiles: {available}"
239
238
  )
240
239
 
241
240
  profile_settings = profile_class.get_settings()
@@ -192,7 +192,6 @@ class GitDataFetcher:
192
192
  description=f"📊 Processing repository: {project_key}",
193
193
  unit="steps",
194
194
  ) as repo_progress_ctx:
195
-
196
195
  # Step 1: Fetch commits
197
196
  progress.set_description(repo_progress_ctx, f"🔍 {project_key}: Fetching commits")
198
197
  daily_commits = self._fetch_commits_by_day(
@@ -538,7 +537,6 @@ class GitDataFetcher:
538
537
  unit="days",
539
538
  nested=True,
540
539
  ) as day_progress_ctx:
541
-
542
540
  for day_date in days_to_process:
543
541
  # Update description to show current repository and day clearly
544
542
  day_str = day_date.strftime("%Y-%m-%d")
@@ -621,6 +621,8 @@ class DeveloperIdentityResolver:
621
621
  canonical_id = self.resolve_developer(commit["author_name"], commit["author_email"])
622
622
  # Update the commit with the resolved canonical_id for later use in reports
623
623
  commit["canonical_id"] = canonical_id
624
+ # Also add the canonical display name so reports show the correct name
625
+ commit["canonical_name"] = self.get_canonical_name(canonical_id)
624
626
 
625
627
  stats_by_dev[canonical_id]["commits"] += 1
626
628
  stats_by_dev[canonical_id]["story_points"] += commit.get("story_points", 0) or 0
@@ -503,7 +503,9 @@ class TicketExtractor:
503
503
  :100
504
504
  ], # Increased from 60 to 100
505
505
  "full_message": commit.get("message", ""),
506
- "author": commit.get("author_name", "Unknown"),
506
+ "author": commit.get(
507
+ "canonical_name", commit.get("author_name", "Unknown")
508
+ ),
507
509
  "author_email": commit.get("author_email", ""),
508
510
  "canonical_id": commit.get("canonical_id", commit.get("author_email", "")),
509
511
  "timestamp": commit.get("timestamp"),
@@ -101,7 +101,7 @@ class GitHubIntegration:
101
101
 
102
102
  if cache_hits > 0 or cache_misses > 0:
103
103
  print(
104
- f" 📊 GitHub PR cache: {cache_hits} hits, {cache_misses} misses ({cache_hits/(cache_hits+cache_misses)*100:.1f}% hit rate)"
104
+ f" 📊 GitHub PR cache: {cache_hits} hits, {cache_misses} misses ({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)"
105
105
  if (cache_hits + cache_misses) > 0
106
106
  else ""
107
107
  )
@@ -186,7 +186,7 @@ class JIRAIntegration:
186
186
 
187
187
  if cache_hits > 0 or cache_misses > 0:
188
188
  print(
189
- f" 📊 JIRA cache: {cache_hits} hits, {cache_misses} misses ({cache_hits/(cache_hits+cache_misses)*100:.1f}% hit rate)"
189
+ f" 📊 JIRA cache: {cache_hits} hits, {cache_misses} misses ({cache_hits / (cache_hits + cache_misses) * 100:.1f}% hit rate)"
190
190
  )
191
191
 
192
192
  # Fetch missing tickets from JIRA
@@ -169,16 +169,16 @@ class ChatGPTQualitativeAnalyzer:
169
169
  def _create_executive_summary_prompt(self, summary_data: dict[str, Any]) -> str:
170
170
  """Create the prompt for ChatGPT."""
171
171
 
172
- prompt = f"""Based on the following GitFlow Analytics data from the past {summary_data['period_weeks']} weeks, provide a comprehensive executive summary with qualitative insights:
172
+ prompt = f"""Based on the following GitFlow Analytics data from the past {summary_data["period_weeks"]} weeks, provide a comprehensive executive summary with qualitative insights:
173
173
 
174
174
  ## Key Metrics:
175
- - Total Commits: {summary_data['total_commits']:,}
176
- - Active Developers: {summary_data['total_developers']}
177
- - Lines Changed: {summary_data['lines_changed']:,}
178
- - Story Points Delivered: {summary_data['story_points']}
179
- - Ticket Coverage: {summary_data['ticket_coverage']:.1f}%
180
- - Team Health Score: {summary_data['team_health_score']:.1f}/100 ({summary_data['team_health_rating']})
181
- - Velocity Trend: {summary_data['velocity_trend']}
175
+ - Total Commits: {summary_data["total_commits"]:,}
176
+ - Active Developers: {summary_data["total_developers"]}
177
+ - Lines Changed: {summary_data["lines_changed"]:,}
178
+ - Story Points Delivered: {summary_data["story_points"]}
179
+ - Ticket Coverage: {summary_data["ticket_coverage"]:.1f}%
180
+ - Team Health Score: {summary_data["team_health_score"]:.1f}/100 ({summary_data["team_health_rating"]})
181
+ - Velocity Trend: {summary_data["velocity_trend"]}
182
182
 
183
183
  ## Top Contributors:
184
184
  """
@@ -222,18 +222,18 @@ Report only statistical patterns, measurable trends, and process gaps. Use factu
222
222
 
223
223
  return f"""## Executive Summary
224
224
 
225
- Over the past {summary_data['period_weeks']} weeks, the development team generated {summary_data['total_commits']:,} commits across {summary_data['total_developers']} active developers.
225
+ Over the past {summary_data["period_weeks"]} weeks, the development team generated {summary_data["total_commits"]:,} commits across {summary_data["total_developers"]} active developers.
226
226
 
227
- The team health score measured {summary_data['team_health_score']:.1f}/100 ({summary_data['team_health_rating']}). Ticket coverage reached {summary_data['ticket_coverage']:.1f}% of total commits with trackable references.
227
+ The team health score measured {summary_data["team_health_score"]:.1f}/100 ({summary_data["team_health_rating"]}). Ticket coverage reached {summary_data["ticket_coverage"]:.1f}% of total commits with trackable references.
228
228
 
229
229
  ### Measured Outputs:
230
- - Code changes: {summary_data['lines_changed']:,} lines modified
231
- - Story points completed: {summary_data['story_points']}
232
- - Velocity trend: {summary_data['velocity_trend']}
230
+ - Code changes: {summary_data["lines_changed"]:,} lines modified
231
+ - Story points completed: {summary_data["story_points"]}
232
+ - Velocity trend: {summary_data["velocity_trend"]}
233
233
 
234
234
  ### Process Recommendations:
235
- 1. {'Maintain current output rate' if summary_data['velocity_trend'] == 'increasing' else 'Analyze velocity decline factors'}
236
- 2. {'Sustain current tracking rate' if summary_data['ticket_coverage'] > 60 else 'Increase commit-ticket linking to reach 70% coverage target'}
235
+ 1. {"Maintain current output rate" if summary_data["velocity_trend"] == "increasing" else "Analyze velocity decline factors"}
236
+ 2. {"Sustain current tracking rate" if summary_data["ticket_coverage"] > 60 else "Increase commit-ticket linking to reach 70% coverage target"}
237
237
  3. Review projects with health scores below 60/100 for process gaps
238
238
 
239
239
  *Note: This is a fallback summary. For detailed analysis, configure ChatGPT integration.*
@@ -353,7 +353,7 @@ Response (format: CATEGORY confidence reasoning):""",
353
353
  """
354
354
  formatted = []
355
355
  for i, example in enumerate(examples, 1):
356
- formatted.append(f"{i}. Message: \"{example['message']}\"")
356
+ formatted.append(f'{i}. Message: "{example["message"]}"')
357
357
  formatted.append(f" Response: {example['response']}")
358
358
  return "\n".join(formatted)
359
359
 
@@ -577,8 +577,7 @@ class QualitativeProcessor:
577
577
  llm_pct = (llm_processed / total_commits) * 100 if total_commits > 0 else 0
578
578
 
579
579
  self.logger.info(
580
- f"Processing breakdown: {cache_pct:.1f}% cached, "
581
- f"{nlp_pct:.1f}% NLP, {llm_pct:.1f}% LLM"
580
+ f"Processing breakdown: {cache_pct:.1f}% cached, {nlp_pct:.1f}% NLP, {llm_pct:.1f}% LLM"
582
581
  )
583
582
 
584
583
  def _should_optimize_cache(self) -> bool:
@@ -906,7 +906,9 @@ class EnhancedQualitativeAnalyzer:
906
906
  "status": (
907
907
  "excellent"
908
908
  if activity_score >= 80
909
- else "good" if activity_score >= 60 else "needs_improvement"
909
+ else "good"
910
+ if activity_score >= 60
911
+ else "needs_improvement"
910
912
  ),
911
913
  },
912
914
  "contributor_diversity": {
@@ -915,7 +917,9 @@ class EnhancedQualitativeAnalyzer:
915
917
  "status": (
916
918
  "excellent"
917
919
  if len(contributors) >= 4
918
- else "good" if len(contributors) >= 2 else "concerning"
920
+ else "good"
921
+ if len(contributors) >= 2
922
+ else "concerning"
919
923
  ),
920
924
  },
921
925
  "pr_velocity": {
@@ -929,7 +933,9 @@ class EnhancedQualitativeAnalyzer:
929
933
  "status": (
930
934
  "excellent"
931
935
  if ticket_coverage >= 80
932
- else "good" if ticket_coverage >= 60 else "needs_improvement"
936
+ else "good"
937
+ if ticket_coverage >= 60
938
+ else "needs_improvement"
933
939
  ),
934
940
  },
935
941
  }
@@ -948,7 +954,9 @@ class EnhancedQualitativeAnalyzer:
948
954
  "status": (
949
955
  "excellent"
950
956
  if overall_score >= 80
951
- else "good" if overall_score >= 60 else "needs_improvement"
957
+ else "good"
958
+ if overall_score >= 60
959
+ else "needs_improvement"
952
960
  ),
953
961
  }
954
962
 
@@ -1918,7 +1926,9 @@ class EnhancedQualitativeAnalyzer:
1918
1926
  "status": (
1919
1927
  "excellent"
1920
1928
  if ticket_coverage >= 80
1921
- else "good" if ticket_coverage >= 60 else "needs_improvement"
1929
+ else "good"
1930
+ if ticket_coverage >= 60
1931
+ else "needs_improvement"
1922
1932
  ),
1923
1933
  },
1924
1934
  "message_quality": {
@@ -1926,7 +1936,9 @@ class EnhancedQualitativeAnalyzer:
1926
1936
  "status": (
1927
1937
  "excellent"
1928
1938
  if message_quality >= 80
1929
- else "good" if message_quality >= 60 else "needs_improvement"
1939
+ else "good"
1940
+ if message_quality >= 60
1941
+ else "needs_improvement"
1930
1942
  ),
1931
1943
  },
1932
1944
  "commit_size_compliance": {
@@ -1934,7 +1946,9 @@ class EnhancedQualitativeAnalyzer:
1934
1946
  "status": (
1935
1947
  "excellent"
1936
1948
  if size_compliance >= 80
1937
- else "good" if size_compliance >= 60 else "needs_improvement"
1949
+ else "good"
1950
+ if size_compliance >= 60
1951
+ else "needs_improvement"
1938
1952
  ),
1939
1953
  },
1940
1954
  "pr_approval_rate": {"score": pr_approval_rate, "status": "good"}, # Placeholder
@@ -1986,7 +2000,9 @@ class EnhancedQualitativeAnalyzer:
1986
2000
  "collaboration_level": (
1987
2001
  "high"
1988
2002
  if collaboration_score >= 70
1989
- else "medium" if collaboration_score >= 40 else "low"
2003
+ else "medium"
2004
+ if collaboration_score >= 40
2005
+ else "low"
1990
2006
  ),
1991
2007
  "patterns": {
1992
2008
  "multi_project_engagement": cross_collaboration_rate >= 50,
@@ -1938,12 +1938,14 @@ class NarrativeReportGenerator:
1938
1938
  }
1939
1939
  )
1940
1940
 
1941
- # Contributor analysis
1941
+ # Contributor analysis - use canonical_id as key for accurate lookup
1942
+ # Store display name separately so we can show the correct name in reports
1943
+ canonical_id = commit.get("canonical_id", "unknown")
1942
1944
  author = commit.get("author", "Unknown")
1943
- if author not in contributors:
1944
- contributors[author] = {"count": 0, "categories": set()}
1945
- contributors[author]["count"] += 1
1946
- contributors[author]["categories"].add(category)
1945
+ if canonical_id not in contributors:
1946
+ contributors[canonical_id] = {"count": 0, "categories": set(), "name": author}
1947
+ contributors[canonical_id]["count"] += 1
1948
+ contributors[canonical_id]["categories"].add(category)
1947
1949
 
1948
1950
  # Project analysis
1949
1951
  project = commit.get("project_key", "UNKNOWN")
@@ -1998,12 +2000,14 @@ class NarrativeReportGenerator:
1998
2000
  contributors.items(), key=lambda x: x[1]["count"], reverse=True
1999
2001
  )
2000
2002
 
2001
- for author, data in sorted_contributors[:5]: # Show top 5
2003
+ for canonical_id, data in sorted_contributors[:5]: # Show top 5
2002
2004
  untracked_count = data["count"]
2003
2005
  pct_of_untracked = (untracked_count / total_untracked) * 100
2006
+ # Get the display name from the stored data
2007
+ author_name = data.get("name", "Unknown")
2004
2008
 
2005
- # Find developer's total commits to calculate percentage of their work that's untracked
2006
- dev_data = dev_lookup.get(author)
2009
+ # Find developer's total commits using canonical_id
2010
+ dev_data = dev_lookup.get(canonical_id)
2007
2011
  if dev_data:
2008
2012
  total_dev_commits = dev_data["total_commits"]
2009
2013
  pct_of_dev_work = (
@@ -2018,7 +2022,7 @@ class NarrativeReportGenerator:
2018
2022
  if len(categories_list) > 3:
2019
2023
  categories_str += f" (+{len(categories_list) - 3} more)"
2020
2024
 
2021
- report.write(f"- **{author}**: {untracked_count} commits ")
2025
+ report.write(f"- **{author_name}**: {untracked_count} commits ")
2022
2026
  report.write(f"({pct_of_untracked:.1f}% of untracked{dev_context}) - ")
2023
2027
  report.write(f"*{categories_str}*\n")
2024
2028
  report.write("\n")
@@ -0,0 +1,5 @@
1
+ """Security reporting module."""
2
+
3
+ from .security_report import SecurityReportGenerator
4
+
5
+ __all__ = ["SecurityReportGenerator"]
@@ -0,0 +1,358 @@
1
+ """Generate security analysis reports."""
2
+
3
+ import json
4
+ import csv
5
+ from typing import List, Dict, Any, Optional
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ from ..security_analyzer import SecurityAnalysis
9
+
10
+
11
+ class SecurityReportGenerator:
12
+ """Generate various format reports for security findings."""
13
+
14
+ def __init__(self, output_dir: Optional[Path] = None):
15
+ """Initialize report generator.
16
+
17
+ Args:
18
+ output_dir: Directory for report output
19
+ """
20
+ self.output_dir = output_dir or Path("reports")
21
+ self.output_dir.mkdir(parents=True, exist_ok=True)
22
+
23
+ def generate_reports(self, analyses: List[SecurityAnalysis], summary: Dict[str, Any]) -> Dict[str, Path]:
24
+ """Generate all report formats.
25
+
26
+ Args:
27
+ analyses: List of security analyses
28
+ summary: Summary statistics
29
+
30
+ Returns:
31
+ Dictionary of report type to file path
32
+ """
33
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
34
+ reports = {}
35
+
36
+ # Generate Markdown report
37
+ md_path = self.output_dir / f"security_report_{timestamp}.md"
38
+ self._generate_markdown_report(analyses, summary, md_path)
39
+ reports["markdown"] = md_path
40
+
41
+ # Generate JSON report
42
+ json_path = self.output_dir / f"security_findings_{timestamp}.json"
43
+ self._generate_json_report(analyses, summary, json_path)
44
+ reports["json"] = json_path
45
+
46
+ # Generate CSV report
47
+ csv_path = self.output_dir / f"security_issues_{timestamp}.csv"
48
+ self._generate_csv_report(analyses, csv_path)
49
+ reports["csv"] = csv_path
50
+
51
+ # Generate SARIF report if requested
52
+ if any(a.total_findings > 0 for a in analyses):
53
+ sarif_path = self.output_dir / f"security_sarif_{timestamp}.json"
54
+ self._generate_sarif_report(analyses, sarif_path)
55
+ reports["sarif"] = sarif_path
56
+
57
+ return reports
58
+
59
+ def _generate_markdown_report(self, analyses: List[SecurityAnalysis], summary: Dict, path: Path) -> None:
60
+ """Generate comprehensive Markdown security report."""
61
+ with open(path, 'w') as f:
62
+ # Header
63
+ f.write("# 🔒 Security Analysis Report\n\n")
64
+ f.write(f"**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
65
+
66
+ # Executive Summary
67
+ f.write("## 📊 Executive Summary\n\n")
68
+ f.write(f"- **Commits Analyzed**: {summary['total_commits']}\n")
69
+ f.write(f"- **Commits with Issues**: {summary['commits_with_issues']}\n")
70
+ f.write(f"- **Total Findings**: {summary['total_findings']}\n")
71
+ f.write(f"- **Risk Level**: **{summary['risk_level']}** (Score: {summary['average_risk_score']})\n\n")
72
+
73
+ # Risk Assessment
74
+ self._write_risk_assessment(f, summary)
75
+
76
+ # Severity Distribution
77
+ f.write("## 🎯 Severity Distribution\n\n")
78
+ severity = summary['severity_distribution']
79
+ if severity['critical'] > 0:
80
+ f.write(f"- 🔴 **Critical**: {severity['critical']}\n")
81
+ if severity['high'] > 0:
82
+ f.write(f"- 🟠 **High**: {severity['high']}\n")
83
+ if severity['medium'] > 0:
84
+ f.write(f"- 🟡 **Medium**: {severity['medium']}\n")
85
+ if severity['low'] > 0:
86
+ f.write(f"- 🟢 **Low**: {severity['low']}\n")
87
+ f.write("\n")
88
+
89
+ # Top Issues
90
+ if summary['top_issues']:
91
+ f.write("## 🔝 Top Security Issues\n\n")
92
+ f.write("| Issue Type | Severity | Occurrences | Affected Files |\n")
93
+ f.write("|------------|----------|-------------|----------------|\n")
94
+ for issue in summary['top_issues']:
95
+ f.write(f"| {issue['type']} | {issue['severity'].upper()} | "
96
+ f"{issue['occurrences']} | {issue['affected_files']} |\n")
97
+ f.write("\n")
98
+
99
+ # Detailed Findings by Category
100
+ self._write_detailed_findings(f, analyses)
101
+
102
+ # LLM Insights
103
+ if 'llm_insights' in summary and summary['llm_insights']:
104
+ f.write("## 🤖 AI Security Insights\n\n")
105
+ f.write(summary['llm_insights'])
106
+ f.write("\n\n")
107
+
108
+ # Recommendations
109
+ f.write("## 💡 Recommendations\n\n")
110
+ for rec in summary['recommendations']:
111
+ f.write(f"- {rec}\n")
112
+ f.write("\n")
113
+
114
+ # Appendix - All Findings
115
+ f.write("## 📋 Detailed Findings\n\n")
116
+ self._write_all_findings(f, analyses)
117
+
118
+ def _write_risk_assessment(self, f, summary: Dict) -> None:
119
+ """Write risk assessment section."""
120
+ risk_level = summary['risk_level']
121
+ score = summary['average_risk_score']
122
+
123
+ f.write("## ⚠️ Risk Assessment\n\n")
124
+
125
+ if risk_level == "CRITICAL":
126
+ f.write("### 🚨 CRITICAL RISK DETECTED\n\n")
127
+ f.write("Immediate action required. Critical security vulnerabilities have been identified "
128
+ "that could lead to severe security breaches.\n\n")
129
+ elif risk_level == "HIGH":
130
+ f.write("### 🔴 High Risk\n\n")
131
+ f.write("Significant security issues detected that should be addressed urgently.\n\n")
132
+ elif risk_level == "MEDIUM":
133
+ f.write("### 🟡 Medium Risk\n\n")
134
+ f.write("Moderate security concerns identified that should be addressed in the near term.\n\n")
135
+ else:
136
+ f.write("### 🟢 Low Risk\n\n")
137
+ f.write("Minor security issues detected. Continue with regular security practices.\n\n")
138
+
139
+ # Risk score visualization
140
+ f.write("**Risk Score Breakdown**:\n")
141
+ f.write("```\n")
142
+ bar_length = 50
143
+ filled = int(score / 100 * bar_length)
144
+ bar = "█" * filled + "░" * (bar_length - filled)
145
+ f.write(f"[{bar}] {score:.1f}/100\n")
146
+ f.write("```\n\n")
147
+
148
+ def _write_detailed_findings(self, f, analyses: List[SecurityAnalysis]) -> None:
149
+ """Write detailed findings by category."""
150
+ # Aggregate findings
151
+ all_secrets = []
152
+ all_vulnerabilities = []
153
+ all_dependencies = []
154
+ all_llm = []
155
+
156
+ for analysis in analyses:
157
+ all_secrets.extend(analysis.secrets)
158
+ all_vulnerabilities.extend(analysis.vulnerabilities)
159
+ all_dependencies.extend(analysis.dependency_issues)
160
+ all_llm.extend(analysis.llm_findings)
161
+
162
+ # Secrets Section
163
+ if all_secrets:
164
+ f.write("## 🔑 Exposed Secrets\n\n")
165
+ f.write(f"**Total**: {len(all_secrets)} potential secrets detected\n\n")
166
+
167
+ # Group by secret type
168
+ by_type = {}
169
+ for secret in all_secrets:
170
+ secret_type = secret.get('secret_type', 'unknown')
171
+ if secret_type not in by_type:
172
+ by_type[secret_type] = []
173
+ by_type[secret_type].append(secret)
174
+
175
+ for secret_type, secrets in sorted(by_type.items()):
176
+ f.write(f"### {secret_type.replace('_', ' ').title()}\n")
177
+ for s in secrets[:5]: # Show first 5 of each type
178
+ f.write(f"- **File**: `{s.get('file', 'unknown')}`\n")
179
+ f.write(f" - Line: {s.get('line', 'N/A')}\n")
180
+ f.write(f" - Pattern: `{s.get('match', 'N/A')}`\n")
181
+ if len(secrets) > 5:
182
+ f.write(f" - *... and {len(secrets) - 5} more*\n")
183
+ f.write("\n")
184
+
185
+ # Vulnerabilities Section
186
+ if all_vulnerabilities:
187
+ f.write("## 🛡️ Code Vulnerabilities\n\n")
188
+ f.write(f"**Total**: {len(all_vulnerabilities)} vulnerabilities detected\n\n")
189
+
190
+ # Group by vulnerability type
191
+ by_type = {}
192
+ for vuln in all_vulnerabilities:
193
+ vuln_type = vuln.get('vulnerability_type', 'unknown')
194
+ if vuln_type not in by_type:
195
+ by_type[vuln_type] = []
196
+ by_type[vuln_type].append(vuln)
197
+
198
+ for vuln_type, vulns in sorted(by_type.items()):
199
+ f.write(f"### {vuln_type.replace('_', ' ').title()}\n")
200
+ for v in vulns[:5]:
201
+ f.write(f"- **File**: `{v.get('file', 'unknown')}:{v.get('line', 'N/A')}`\n")
202
+ f.write(f" - Tool: {v.get('tool', 'N/A')}\n")
203
+ f.write(f" - Message: {v.get('message', 'N/A')}\n")
204
+ if len(vulns) > 5:
205
+ f.write(f" - *... and {len(vulns) - 5} more*\n")
206
+ f.write("\n")
207
+
208
+ # Dependencies Section
209
+ if all_dependencies:
210
+ f.write("## 📦 Vulnerable Dependencies\n\n")
211
+ f.write(f"**Total**: {len(all_dependencies)} vulnerable dependencies\n\n")
212
+
213
+ for dep in all_dependencies[:10]:
214
+ f.write(f"- **{dep.get('package', 'unknown')}** @ {dep.get('version', 'unknown')}\n")
215
+ f.write(f" - File: `{dep.get('file', 'unknown')}`\n")
216
+ if dep.get('cve'):
217
+ f.write(f" - CVE: {dep['cve']}\n")
218
+ f.write(f" - Message: {dep.get('message', 'N/A')}\n")
219
+ if len(all_dependencies) > 10:
220
+ f.write(f"\n*... and {len(all_dependencies) - 10} more vulnerable dependencies*\n")
221
+ f.write("\n")
222
+
223
+ def _write_all_findings(self, f, analyses: List[SecurityAnalysis]) -> None:
224
+ """Write all findings in detail."""
225
+ for analysis in analyses:
226
+ if analysis.total_findings == 0:
227
+ continue
228
+
229
+ f.write(f"### Commit: `{analysis.commit_hash[:8]}`\n")
230
+ f.write(f"**Time**: {analysis.timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n")
231
+ f.write(f"**Files Changed**: {len(analysis.files_changed)}\n")
232
+ f.write(f"**Risk Score**: {analysis.risk_score:.1f}\n\n")
233
+
234
+ if analysis.secrets:
235
+ f.write("**Secrets**:\n")
236
+ for s in analysis.secrets:
237
+ f.write(f"- {s.get('secret_type', 'unknown')}: {s.get('file', 'N/A')}\n")
238
+
239
+ if analysis.vulnerabilities:
240
+ f.write("**Vulnerabilities**:\n")
241
+ for v in analysis.vulnerabilities:
242
+ f.write(f"- {v.get('vulnerability_type', 'unknown')}: {v.get('file', 'N/A')}\n")
243
+
244
+ f.write("\n---\n\n")
245
+
246
+ def _generate_json_report(self, analyses: List[SecurityAnalysis], summary: Dict, path: Path) -> None:
247
+ """Generate JSON report with all findings."""
248
+ report = {
249
+ "metadata": {
250
+ "generated": datetime.now().isoformat(),
251
+ "version": "1.0.0"
252
+ },
253
+ "summary": summary,
254
+ "analyses": []
255
+ }
256
+
257
+ for analysis in analyses:
258
+ report["analyses"].append({
259
+ "commit_hash": analysis.commit_hash,
260
+ "timestamp": analysis.timestamp.isoformat(),
261
+ "files_changed": analysis.files_changed,
262
+ "risk_score": analysis.risk_score,
263
+ "findings": {
264
+ "secrets": analysis.secrets,
265
+ "vulnerabilities": analysis.vulnerabilities,
266
+ "dependency_issues": analysis.dependency_issues,
267
+ "llm_findings": analysis.llm_findings
268
+ },
269
+ "metrics": {
270
+ "total": analysis.total_findings,
271
+ "critical": analysis.critical_count,
272
+ "high": analysis.high_count,
273
+ "medium": analysis.medium_count,
274
+ "low": analysis.low_count
275
+ }
276
+ })
277
+
278
+ with open(path, 'w') as f:
279
+ json.dump(report, f, indent=2)
280
+
281
+ def _generate_csv_report(self, analyses: List[SecurityAnalysis], path: Path) -> None:
282
+ """Generate CSV report of all findings."""
283
+ with open(path, 'w', newline='') as f:
284
+ writer = csv.DictWriter(f, fieldnames=[
285
+ 'commit_hash', 'timestamp', 'type', 'severity',
286
+ 'category', 'file', 'line', 'message', 'tool', 'confidence'
287
+ ])
288
+ writer.writeheader()
289
+
290
+ for analysis in analyses:
291
+ # Write all findings
292
+ for finding in (analysis.secrets + analysis.vulnerabilities +
293
+ analysis.dependency_issues + analysis.llm_findings):
294
+ writer.writerow({
295
+ 'commit_hash': analysis.commit_hash[:8],
296
+ 'timestamp': analysis.timestamp.isoformat(),
297
+ 'type': finding.get('type', 'unknown'),
298
+ 'severity': finding.get('severity', 'medium'),
299
+ 'category': finding.get('vulnerability_type',
300
+ finding.get('secret_type', 'unknown')),
301
+ 'file': finding.get('file', ''),
302
+ 'line': finding.get('line', ''),
303
+ 'message': finding.get('message', ''),
304
+ 'tool': finding.get('tool', finding.get('source', '')),
305
+ 'confidence': finding.get('confidence', '')
306
+ })
307
+
308
+ def _generate_sarif_report(self, analyses: List[SecurityAnalysis], path: Path) -> None:
309
+ """Generate SARIF format report for GitHub Security tab integration."""
310
+ sarif = {
311
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
312
+ "version": "2.1.0",
313
+ "runs": [{
314
+ "tool": {
315
+ "driver": {
316
+ "name": "GitFlow Analytics Security",
317
+ "version": "1.0.0",
318
+ "informationUri": "https://github.com/yourusername/gitflow-analytics"
319
+ }
320
+ },
321
+ "results": []
322
+ }]
323
+ }
324
+
325
+ for analysis in analyses:
326
+ for finding in (analysis.secrets + analysis.vulnerabilities):
327
+ result = {
328
+ "ruleId": finding.get('vulnerability_type',
329
+ finding.get('secret_type', 'unknown')),
330
+ "level": self._severity_to_sarif_level(finding.get('severity', 'medium')),
331
+ "message": {
332
+ "text": finding.get('message', 'Security issue detected')
333
+ },
334
+ "locations": [{
335
+ "physicalLocation": {
336
+ "artifactLocation": {
337
+ "uri": finding.get('file', 'unknown')
338
+ },
339
+ "region": {
340
+ "startLine": finding.get('line', 1)
341
+ }
342
+ }
343
+ }]
344
+ }
345
+ sarif["runs"][0]["results"].append(result)
346
+
347
+ with open(path, 'w') as f:
348
+ json.dump(sarif, f, indent=2)
349
+
350
+ def _severity_to_sarif_level(self, severity: str) -> str:
351
+ """Convert severity to SARIF level."""
352
+ mapping = {
353
+ "critical": "error",
354
+ "high": "error",
355
+ "medium": "warning",
356
+ "low": "note"
357
+ }
358
+ return mapping.get(severity.lower(), "warning")