gitflow-analytics 1.0.3__py3-none-any.whl โ 1.3.11__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.
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/classification/__init__.py +31 -0
- gitflow_analytics/classification/batch_classifier.py +752 -0
- gitflow_analytics/classification/classifier.py +464 -0
- gitflow_analytics/classification/feature_extractor.py +725 -0
- gitflow_analytics/classification/linguist_analyzer.py +574 -0
- gitflow_analytics/classification/model.py +455 -0
- gitflow_analytics/cli.py +4158 -350
- gitflow_analytics/cli_rich.py +198 -48
- gitflow_analytics/config/__init__.py +43 -0
- gitflow_analytics/config/errors.py +261 -0
- gitflow_analytics/config/loader.py +905 -0
- gitflow_analytics/config/profiles.py +264 -0
- gitflow_analytics/config/repository.py +124 -0
- gitflow_analytics/config/schema.py +444 -0
- gitflow_analytics/config/validator.py +154 -0
- gitflow_analytics/config.py +44 -508
- gitflow_analytics/core/analyzer.py +1209 -98
- gitflow_analytics/core/cache.py +1337 -29
- gitflow_analytics/core/data_fetcher.py +1285 -0
- gitflow_analytics/core/identity.py +363 -14
- gitflow_analytics/core/metrics_storage.py +526 -0
- gitflow_analytics/core/progress.py +372 -0
- gitflow_analytics/core/schema_version.py +269 -0
- gitflow_analytics/extractors/ml_tickets.py +1100 -0
- gitflow_analytics/extractors/story_points.py +8 -1
- gitflow_analytics/extractors/tickets.py +749 -11
- gitflow_analytics/identity_llm/__init__.py +6 -0
- gitflow_analytics/identity_llm/analysis_pass.py +231 -0
- gitflow_analytics/identity_llm/analyzer.py +464 -0
- gitflow_analytics/identity_llm/models.py +76 -0
- gitflow_analytics/integrations/github_integration.py +175 -11
- gitflow_analytics/integrations/jira_integration.py +461 -24
- gitflow_analytics/integrations/orchestrator.py +124 -1
- gitflow_analytics/metrics/activity_scoring.py +322 -0
- gitflow_analytics/metrics/branch_health.py +470 -0
- gitflow_analytics/metrics/dora.py +379 -20
- gitflow_analytics/models/database.py +843 -53
- gitflow_analytics/pm_framework/__init__.py +115 -0
- gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
- gitflow_analytics/pm_framework/base.py +406 -0
- gitflow_analytics/pm_framework/models.py +211 -0
- gitflow_analytics/pm_framework/orchestrator.py +652 -0
- gitflow_analytics/pm_framework/registry.py +333 -0
- gitflow_analytics/qualitative/__init__.py +9 -10
- gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
- gitflow_analytics/qualitative/classifiers/__init__.py +3 -3
- gitflow_analytics/qualitative/classifiers/change_type.py +518 -244
- gitflow_analytics/qualitative/classifiers/domain_classifier.py +272 -165
- gitflow_analytics/qualitative/classifiers/intent_analyzer.py +321 -222
- gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
- gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
- gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
- gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
- gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
- gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
- gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
- gitflow_analytics/qualitative/classifiers/risk_analyzer.py +215 -189
- gitflow_analytics/qualitative/core/__init__.py +4 -4
- gitflow_analytics/qualitative/core/llm_fallback.py +239 -235
- gitflow_analytics/qualitative/core/nlp_engine.py +157 -148
- gitflow_analytics/qualitative/core/pattern_cache.py +214 -192
- gitflow_analytics/qualitative/core/processor.py +381 -248
- gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
- gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
- gitflow_analytics/qualitative/models/__init__.py +7 -7
- gitflow_analytics/qualitative/models/schemas.py +155 -121
- gitflow_analytics/qualitative/utils/__init__.py +4 -4
- gitflow_analytics/qualitative/utils/batch_processor.py +136 -123
- gitflow_analytics/qualitative/utils/cost_tracker.py +142 -140
- gitflow_analytics/qualitative/utils/metrics.py +172 -158
- gitflow_analytics/qualitative/utils/text_processing.py +146 -104
- gitflow_analytics/reports/__init__.py +100 -0
- gitflow_analytics/reports/analytics_writer.py +539 -14
- gitflow_analytics/reports/base.py +648 -0
- gitflow_analytics/reports/branch_health_writer.py +322 -0
- gitflow_analytics/reports/classification_writer.py +924 -0
- gitflow_analytics/reports/cli_integration.py +427 -0
- gitflow_analytics/reports/csv_writer.py +1676 -212
- gitflow_analytics/reports/data_models.py +504 -0
- gitflow_analytics/reports/database_report_generator.py +427 -0
- gitflow_analytics/reports/example_usage.py +344 -0
- gitflow_analytics/reports/factory.py +499 -0
- gitflow_analytics/reports/formatters.py +698 -0
- gitflow_analytics/reports/html_generator.py +1116 -0
- gitflow_analytics/reports/interfaces.py +489 -0
- gitflow_analytics/reports/json_exporter.py +2770 -0
- gitflow_analytics/reports/narrative_writer.py +2287 -158
- gitflow_analytics/reports/story_point_correlation.py +1144 -0
- gitflow_analytics/reports/weekly_trends_writer.py +389 -0
- gitflow_analytics/training/__init__.py +5 -0
- gitflow_analytics/training/model_loader.py +377 -0
- gitflow_analytics/training/pipeline.py +550 -0
- gitflow_analytics/tui/__init__.py +1 -1
- gitflow_analytics/tui/app.py +129 -126
- gitflow_analytics/tui/screens/__init__.py +3 -3
- gitflow_analytics/tui/screens/analysis_progress_screen.py +188 -179
- gitflow_analytics/tui/screens/configuration_screen.py +154 -178
- gitflow_analytics/tui/screens/loading_screen.py +100 -110
- gitflow_analytics/tui/screens/main_screen.py +89 -72
- gitflow_analytics/tui/screens/results_screen.py +305 -281
- gitflow_analytics/tui/widgets/__init__.py +2 -2
- gitflow_analytics/tui/widgets/data_table.py +67 -69
- gitflow_analytics/tui/widgets/export_modal.py +76 -76
- gitflow_analytics/tui/widgets/progress_widget.py +41 -46
- gitflow_analytics-1.3.11.dist-info/METADATA +1015 -0
- gitflow_analytics-1.3.11.dist-info/RECORD +122 -0
- gitflow_analytics-1.0.3.dist-info/METADATA +0 -490
- gitflow_analytics-1.0.3.dist-info/RECORD +0 -62
- {gitflow_analytics-1.0.3.dist-info โ gitflow_analytics-1.3.11.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.3.dist-info โ gitflow_analytics-1.3.11.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.3.dist-info โ gitflow_analytics-1.3.11.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-1.0.3.dist-info โ gitflow_analytics-1.3.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Branch health metrics for project health assessment.
|
|
2
|
+
|
|
3
|
+
Based on 2025 software engineering best practices, this module analyzes
|
|
4
|
+
branch patterns to assess project health, integration practices, and
|
|
5
|
+
development workflow efficiency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import git
|
|
13
|
+
from git import Repo
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BranchHealthAnalyzer:
|
|
19
|
+
"""Analyze branch patterns and health metrics for repositories."""
|
|
20
|
+
|
|
21
|
+
# Default thresholds based on 2025 best practices
|
|
22
|
+
DEFAULT_STALE_BRANCH_DAYS = 30
|
|
23
|
+
DEFAULT_HEALTHY_BRANCH_COUNT = 10
|
|
24
|
+
DEFAULT_LONG_LIVED_BRANCH_DAYS = 14
|
|
25
|
+
DEFAULT_IDEAL_PR_SIZE_LINES = 200
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
stale_branch_days: int = DEFAULT_STALE_BRANCH_DAYS,
|
|
30
|
+
healthy_branch_count: int = DEFAULT_HEALTHY_BRANCH_COUNT,
|
|
31
|
+
long_lived_branch_days: int = DEFAULT_LONG_LIVED_BRANCH_DAYS,
|
|
32
|
+
):
|
|
33
|
+
"""Initialize branch health analyzer with configurable thresholds."""
|
|
34
|
+
self.stale_branch_days = stale_branch_days
|
|
35
|
+
self.healthy_branch_count = healthy_branch_count
|
|
36
|
+
self.long_lived_branch_days = long_lived_branch_days
|
|
37
|
+
|
|
38
|
+
def analyze_repository_branches(self, repo_path: str) -> dict[str, Any]:
|
|
39
|
+
"""Analyze all branches in a repository for health metrics.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
repo_path: Path to the git repository
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dictionary containing comprehensive branch health metrics
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
repo = Repo(repo_path)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(f"Failed to open repository at {repo_path}: {e}")
|
|
51
|
+
return self._empty_metrics()
|
|
52
|
+
|
|
53
|
+
now = datetime.now(timezone.utc)
|
|
54
|
+
metrics = {
|
|
55
|
+
"analysis_timestamp": now.isoformat(),
|
|
56
|
+
"repository_path": repo_path,
|
|
57
|
+
"branches": {},
|
|
58
|
+
"summary": {},
|
|
59
|
+
"health_indicators": {},
|
|
60
|
+
"recommendations": [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Identify main/master branch
|
|
64
|
+
main_branch = self._identify_main_branch(repo)
|
|
65
|
+
if not main_branch:
|
|
66
|
+
logger.warning(f"Could not identify main branch for {repo_path}")
|
|
67
|
+
return metrics
|
|
68
|
+
|
|
69
|
+
metrics["main_branch"] = main_branch.name
|
|
70
|
+
|
|
71
|
+
# Analyze all branches
|
|
72
|
+
all_branches = list(repo.heads)
|
|
73
|
+
remote_branches = [ref for ref in repo.refs if ref.name.startswith("origin/")]
|
|
74
|
+
|
|
75
|
+
# Track branch categories
|
|
76
|
+
active_branches = []
|
|
77
|
+
stale_branches = []
|
|
78
|
+
long_lived_branches = []
|
|
79
|
+
|
|
80
|
+
for branch in all_branches:
|
|
81
|
+
branch_data = self._analyze_branch(repo, branch, main_branch, now)
|
|
82
|
+
metrics["branches"][branch.name] = branch_data
|
|
83
|
+
|
|
84
|
+
# Categorize branches
|
|
85
|
+
if branch_data["is_stale"]:
|
|
86
|
+
stale_branches.append(branch.name)
|
|
87
|
+
elif branch_data["age_days"] > self.long_lived_branch_days:
|
|
88
|
+
long_lived_branches.append(branch.name)
|
|
89
|
+
else:
|
|
90
|
+
active_branches.append(branch.name)
|
|
91
|
+
|
|
92
|
+
# Calculate summary metrics
|
|
93
|
+
metrics["summary"] = {
|
|
94
|
+
"total_branches": len(all_branches),
|
|
95
|
+
"active_branches": len(active_branches),
|
|
96
|
+
"stale_branches": len(stale_branches),
|
|
97
|
+
"long_lived_branches": len(long_lived_branches),
|
|
98
|
+
"remote_branches": len(remote_branches),
|
|
99
|
+
"branch_creation_rate_per_week": self._calculate_creation_rate(repo, all_branches),
|
|
100
|
+
"average_branch_age_days": self._calculate_average_age(metrics["branches"]),
|
|
101
|
+
"average_commits_per_branch": self._calculate_average_commits(metrics["branches"]),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Calculate health indicators
|
|
105
|
+
metrics["health_indicators"] = self._calculate_health_indicators(metrics["summary"])
|
|
106
|
+
|
|
107
|
+
# Generate recommendations
|
|
108
|
+
metrics["recommendations"] = self._generate_recommendations(metrics)
|
|
109
|
+
|
|
110
|
+
return metrics
|
|
111
|
+
|
|
112
|
+
def _identify_main_branch(self, repo: Repo) -> Optional[git.Head]:
|
|
113
|
+
"""Identify the main/master branch of the repository."""
|
|
114
|
+
# Common main branch names
|
|
115
|
+
main_branch_names = ["main", "master", "develop", "trunk"]
|
|
116
|
+
|
|
117
|
+
for name in main_branch_names:
|
|
118
|
+
try:
|
|
119
|
+
return repo.heads[name]
|
|
120
|
+
except IndexError:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# If no standard names, use the branch with most commits
|
|
124
|
+
if repo.heads:
|
|
125
|
+
return max(repo.heads, key=lambda b: len(list(repo.iter_commits(b))))
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def _analyze_branch(
|
|
130
|
+
self, repo: Repo, branch: git.Head, main_branch: git.Head, now: datetime
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Analyze a single branch for health metrics."""
|
|
133
|
+
try:
|
|
134
|
+
# Get branch commits
|
|
135
|
+
branch_commits = list(repo.iter_commits(branch))
|
|
136
|
+
if not branch_commits:
|
|
137
|
+
return self._empty_branch_metrics(branch.name)
|
|
138
|
+
|
|
139
|
+
# Get latest commit info
|
|
140
|
+
latest_commit = branch_commits[0]
|
|
141
|
+
latest_activity = latest_commit.committed_datetime
|
|
142
|
+
if latest_activity.tzinfo is None:
|
|
143
|
+
latest_activity = latest_activity.replace(tzinfo=timezone.utc)
|
|
144
|
+
|
|
145
|
+
# Calculate age
|
|
146
|
+
age_delta = now - latest_activity
|
|
147
|
+
age_days = age_delta.days
|
|
148
|
+
|
|
149
|
+
# Check if branch is merged
|
|
150
|
+
is_merged = self._is_branch_merged(repo, branch, main_branch)
|
|
151
|
+
|
|
152
|
+
# Calculate divergence from main
|
|
153
|
+
ahead, behind = self._calculate_divergence(repo, branch, main_branch)
|
|
154
|
+
|
|
155
|
+
# Analyze commit patterns
|
|
156
|
+
commit_frequency = self._analyze_commit_frequency(branch_commits)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"name": branch.name,
|
|
160
|
+
"latest_activity": latest_activity.isoformat(),
|
|
161
|
+
"age_days": age_days,
|
|
162
|
+
"is_stale": age_days > self.stale_branch_days,
|
|
163
|
+
"is_merged": is_merged,
|
|
164
|
+
"total_commits": len(branch_commits),
|
|
165
|
+
"unique_authors": len(set(c.author.email for c in branch_commits if c.author)),
|
|
166
|
+
"ahead_of_main": ahead,
|
|
167
|
+
"behind_main": behind,
|
|
168
|
+
"divergence_score": ahead + behind,
|
|
169
|
+
"commit_frequency": commit_frequency,
|
|
170
|
+
"health_score": self._calculate_branch_health_score(
|
|
171
|
+
age_days, ahead, behind, is_merged, len(branch_commits)
|
|
172
|
+
),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(f"Error analyzing branch {branch.name}: {e}")
|
|
177
|
+
return self._empty_branch_metrics(branch.name)
|
|
178
|
+
|
|
179
|
+
def _is_branch_merged(self, repo: Repo, branch: git.Head, main_branch: git.Head) -> bool:
|
|
180
|
+
"""Check if a branch has been merged into main."""
|
|
181
|
+
try:
|
|
182
|
+
# Get merge base
|
|
183
|
+
merge_base = repo.merge_base(branch, main_branch)
|
|
184
|
+
if not merge_base:
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
# If branch tip is in main's history, it's merged
|
|
188
|
+
branch_tip = branch.commit
|
|
189
|
+
main_commits = set(repo.iter_commits(main_branch))
|
|
190
|
+
return branch_tip in main_commits
|
|
191
|
+
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def _calculate_divergence(
|
|
196
|
+
self, repo: Repo, branch: git.Head, main_branch: git.Head
|
|
197
|
+
) -> tuple[int, int]:
|
|
198
|
+
"""Calculate how many commits a branch is ahead/behind main."""
|
|
199
|
+
try:
|
|
200
|
+
# Get commits ahead (in branch but not in main)
|
|
201
|
+
ahead = list(repo.iter_commits(f"{main_branch.name}..{branch.name}"))
|
|
202
|
+
|
|
203
|
+
# Get commits behind (in main but not in branch)
|
|
204
|
+
behind = list(repo.iter_commits(f"{branch.name}..{main_branch.name}"))
|
|
205
|
+
|
|
206
|
+
return len(ahead), len(behind)
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Error calculating divergence: {e}")
|
|
210
|
+
return 0, 0
|
|
211
|
+
|
|
212
|
+
def _analyze_commit_frequency(self, commits: list[git.Commit]) -> dict[str, Any]:
|
|
213
|
+
"""Analyze commit frequency patterns."""
|
|
214
|
+
if not commits:
|
|
215
|
+
return {"daily_average": 0, "weekly_average": 0}
|
|
216
|
+
|
|
217
|
+
# Sort commits by date
|
|
218
|
+
sorted_commits = sorted(commits, key=lambda c: c.committed_datetime)
|
|
219
|
+
|
|
220
|
+
# Calculate date range
|
|
221
|
+
first_date = sorted_commits[0].committed_datetime
|
|
222
|
+
last_date = sorted_commits[-1].committed_datetime
|
|
223
|
+
|
|
224
|
+
if first_date.tzinfo is None:
|
|
225
|
+
first_date = first_date.replace(tzinfo=timezone.utc)
|
|
226
|
+
if last_date.tzinfo is None:
|
|
227
|
+
last_date = last_date.replace(tzinfo=timezone.utc)
|
|
228
|
+
|
|
229
|
+
duration_days = max((last_date - first_date).days, 1)
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
"daily_average": len(commits) / duration_days,
|
|
233
|
+
"weekly_average": (len(commits) / duration_days) * 7,
|
|
234
|
+
"total_days": duration_days,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def _calculate_branch_health_score(
|
|
238
|
+
self, age_days: int, ahead: int, behind: int, is_merged: bool, commit_count: int
|
|
239
|
+
) -> float:
|
|
240
|
+
"""Calculate a health score for a branch (0-100)."""
|
|
241
|
+
if is_merged:
|
|
242
|
+
return 100.0 # Merged branches are healthy
|
|
243
|
+
|
|
244
|
+
score = 100.0
|
|
245
|
+
|
|
246
|
+
# Penalize for age
|
|
247
|
+
if age_days > self.stale_branch_days:
|
|
248
|
+
score -= 40
|
|
249
|
+
elif age_days > self.long_lived_branch_days:
|
|
250
|
+
score -= 20
|
|
251
|
+
elif age_days > 7:
|
|
252
|
+
score -= 10
|
|
253
|
+
|
|
254
|
+
# Penalize for divergence
|
|
255
|
+
if behind > 100:
|
|
256
|
+
score -= 30
|
|
257
|
+
elif behind > 50:
|
|
258
|
+
score -= 20
|
|
259
|
+
elif behind > 20:
|
|
260
|
+
score -= 10
|
|
261
|
+
|
|
262
|
+
# Penalize for being too far ahead (large PRs)
|
|
263
|
+
if ahead > 50:
|
|
264
|
+
score -= 15
|
|
265
|
+
elif ahead > 20:
|
|
266
|
+
score -= 5
|
|
267
|
+
|
|
268
|
+
# Bonus for regular activity
|
|
269
|
+
if commit_count > 1 and age_days < 7:
|
|
270
|
+
score += 10
|
|
271
|
+
|
|
272
|
+
return max(0, min(100, score))
|
|
273
|
+
|
|
274
|
+
def _calculate_creation_rate(self, repo: Repo, branches: list[git.Head]) -> float:
|
|
275
|
+
"""Calculate branch creation rate per week."""
|
|
276
|
+
# This is an approximation based on first commit dates
|
|
277
|
+
creation_dates = []
|
|
278
|
+
|
|
279
|
+
for branch in branches:
|
|
280
|
+
try:
|
|
281
|
+
commits = list(repo.iter_commits(branch))
|
|
282
|
+
if commits:
|
|
283
|
+
first_commit = commits[-1]
|
|
284
|
+
creation_date = first_commit.committed_datetime
|
|
285
|
+
if creation_date.tzinfo is None:
|
|
286
|
+
creation_date = creation_date.replace(tzinfo=timezone.utc)
|
|
287
|
+
creation_dates.append(creation_date)
|
|
288
|
+
except Exception:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
if len(creation_dates) < 2:
|
|
292
|
+
return 0.0
|
|
293
|
+
|
|
294
|
+
# Calculate rate over the past 4 weeks
|
|
295
|
+
now = datetime.now(timezone.utc)
|
|
296
|
+
four_weeks_ago = now - timedelta(weeks=4)
|
|
297
|
+
recent_branches = sum(1 for d in creation_dates if d > four_weeks_ago)
|
|
298
|
+
|
|
299
|
+
return recent_branches / 4.0
|
|
300
|
+
|
|
301
|
+
def _calculate_average_age(self, branches: dict[str, dict[str, Any]]) -> float:
|
|
302
|
+
"""Calculate average age of active branches."""
|
|
303
|
+
active_ages = [
|
|
304
|
+
b["age_days"]
|
|
305
|
+
for b in branches.values()
|
|
306
|
+
if not b.get("is_merged", False) and b.get("age_days", 0) > 0
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
return sum(active_ages) / len(active_ages) if active_ages else 0.0
|
|
310
|
+
|
|
311
|
+
def _calculate_average_commits(self, branches: dict[str, dict[str, Any]]) -> float:
|
|
312
|
+
"""Calculate average commits per branch."""
|
|
313
|
+
commit_counts = [b.get("total_commits", 0) for b in branches.values()]
|
|
314
|
+
return sum(commit_counts) / len(commit_counts) if commit_counts else 0.0
|
|
315
|
+
|
|
316
|
+
def _calculate_health_indicators(self, summary: dict[str, Any]) -> dict[str, Any]:
|
|
317
|
+
"""Calculate overall health indicators based on 2025 best practices."""
|
|
318
|
+
total = summary["total_branches"]
|
|
319
|
+
stale = summary["stale_branches"]
|
|
320
|
+
active = summary["active_branches"]
|
|
321
|
+
|
|
322
|
+
# Calculate health percentages
|
|
323
|
+
stale_percentage = (stale / total * 100) if total > 0 else 0
|
|
324
|
+
|
|
325
|
+
# Determine health status
|
|
326
|
+
if stale_percentage > 50:
|
|
327
|
+
branch_health = "poor"
|
|
328
|
+
elif stale_percentage > 30:
|
|
329
|
+
branch_health = "fair"
|
|
330
|
+
elif stale_percentage > 15:
|
|
331
|
+
branch_health = "good"
|
|
332
|
+
else:
|
|
333
|
+
branch_health = "excellent"
|
|
334
|
+
|
|
335
|
+
# Check branch count health
|
|
336
|
+
if total > self.healthy_branch_count * 2:
|
|
337
|
+
count_health = "poor"
|
|
338
|
+
elif total > self.healthy_branch_count:
|
|
339
|
+
count_health = "fair"
|
|
340
|
+
else:
|
|
341
|
+
count_health = "good"
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"overall_health": branch_health,
|
|
345
|
+
"branch_count_health": count_health,
|
|
346
|
+
"stale_branch_percentage": round(stale_percentage, 1),
|
|
347
|
+
"active_branch_percentage": round((active / total * 100) if total > 0 else 0, 1),
|
|
348
|
+
"integration_frequency": (
|
|
349
|
+
"daily" if summary.get("branch_creation_rate_per_week", 0) > 7 else "weekly"
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
def _generate_recommendations(self, metrics: dict[str, Any]) -> list[str]:
|
|
354
|
+
"""Generate actionable recommendations based on metrics."""
|
|
355
|
+
recommendations = []
|
|
356
|
+
summary = metrics["summary"]
|
|
357
|
+
health = metrics["health_indicators"]
|
|
358
|
+
|
|
359
|
+
# Check stale branches
|
|
360
|
+
if summary["stale_branches"] > 0:
|
|
361
|
+
recommendations.append(
|
|
362
|
+
f"๐งน Clean up {summary['stale_branches']} stale branches "
|
|
363
|
+
f"(inactive for >{self.stale_branch_days} days)"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Check branch count
|
|
367
|
+
if summary["total_branches"] > self.healthy_branch_count:
|
|
368
|
+
recommendations.append(
|
|
369
|
+
f"๐ Consider reducing active branches from {summary['total_branches']} "
|
|
370
|
+
f"to under {self.healthy_branch_count} for better focus"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Check long-lived branches
|
|
374
|
+
if summary["long_lived_branches"] > 3:
|
|
375
|
+
recommendations.append(
|
|
376
|
+
f"โฑ๏ธ Review {summary['long_lived_branches']} long-lived branches - "
|
|
377
|
+
"consider smaller, more frequent integrations"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Check for branches far behind main
|
|
381
|
+
behind_branches = [
|
|
382
|
+
name
|
|
383
|
+
for name, data in metrics["branches"].items()
|
|
384
|
+
if data.get("behind_main", 0) > 50 and not data.get("is_merged", False)
|
|
385
|
+
]
|
|
386
|
+
if behind_branches:
|
|
387
|
+
recommendations.append(
|
|
388
|
+
f"๐ Update {len(behind_branches)} branches that are >50 commits behind main"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Positive feedback
|
|
392
|
+
if health["overall_health"] == "excellent":
|
|
393
|
+
recommendations.append("โ
Excellent branch hygiene! Keep up the good practices")
|
|
394
|
+
|
|
395
|
+
return recommendations
|
|
396
|
+
|
|
397
|
+
def _empty_metrics(self) -> dict[str, Any]:
|
|
398
|
+
"""Return empty metrics structure."""
|
|
399
|
+
return {
|
|
400
|
+
"analysis_timestamp": datetime.now(timezone.utc).isoformat(),
|
|
401
|
+
"repository_path": "",
|
|
402
|
+
"branches": {},
|
|
403
|
+
"summary": {},
|
|
404
|
+
"health_indicators": {},
|
|
405
|
+
"recommendations": [],
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
def _empty_branch_metrics(self, branch_name: str) -> dict[str, Any]:
|
|
409
|
+
"""Return empty metrics for a branch."""
|
|
410
|
+
return {
|
|
411
|
+
"name": branch_name,
|
|
412
|
+
"latest_activity": None,
|
|
413
|
+
"age_days": 0,
|
|
414
|
+
"is_stale": False,
|
|
415
|
+
"is_merged": False,
|
|
416
|
+
"total_commits": 0,
|
|
417
|
+
"unique_authors": 0,
|
|
418
|
+
"ahead_of_main": 0,
|
|
419
|
+
"behind_main": 0,
|
|
420
|
+
"divergence_score": 0,
|
|
421
|
+
"commit_frequency": {"daily_average": 0, "weekly_average": 0},
|
|
422
|
+
"health_score": 0.0,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
def analyze_multiple_repositories(self, repo_paths: list[str]) -> dict[str, dict[str, Any]]:
|
|
426
|
+
"""Analyze branch health across multiple repositories.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
repo_paths: List of repository paths to analyze
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Dictionary mapping repo paths to their health metrics
|
|
433
|
+
"""
|
|
434
|
+
results = {}
|
|
435
|
+
|
|
436
|
+
for repo_path in repo_paths:
|
|
437
|
+
logger.info(f"Analyzing branch health for {repo_path}")
|
|
438
|
+
results[repo_path] = self.analyze_repository_branches(repo_path)
|
|
439
|
+
|
|
440
|
+
return results
|
|
441
|
+
|
|
442
|
+
def generate_aggregate_metrics(
|
|
443
|
+
self, multi_repo_results: dict[str, dict[str, Any]]
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""Generate aggregate metrics across multiple repositories."""
|
|
446
|
+
if not multi_repo_results:
|
|
447
|
+
return {}
|
|
448
|
+
|
|
449
|
+
total_branches = 0
|
|
450
|
+
total_stale = 0
|
|
451
|
+
total_active = 0
|
|
452
|
+
all_recommendations = []
|
|
453
|
+
|
|
454
|
+
for _repo_path, metrics in multi_repo_results.items():
|
|
455
|
+
summary = metrics.get("summary", {})
|
|
456
|
+
total_branches += summary.get("total_branches", 0)
|
|
457
|
+
total_stale += summary.get("stale_branches", 0)
|
|
458
|
+
total_active += summary.get("active_branches", 0)
|
|
459
|
+
all_recommendations.extend(metrics.get("recommendations", []))
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
"total_repositories": len(multi_repo_results),
|
|
463
|
+
"total_branches_all_repos": total_branches,
|
|
464
|
+
"total_stale_branches": total_stale,
|
|
465
|
+
"total_active_branches": total_active,
|
|
466
|
+
"average_branches_per_repo": (
|
|
467
|
+
total_branches / len(multi_repo_results) if multi_repo_results else 0
|
|
468
|
+
),
|
|
469
|
+
"aggregate_recommendations": list(set(all_recommendations)), # Unique recommendations
|
|
470
|
+
}
|