gitflow-analytics 3.6.2__py3-none-any.whl → 3.7.0__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/__init__.py +8 -12
  2. gitflow_analytics/_version.py +1 -1
  3. gitflow_analytics/cli.py +151 -170
  4. gitflow_analytics/cli_wizards/install_wizard.py +5 -5
  5. gitflow_analytics/models/database.py +229 -8
  6. gitflow_analytics/security/reports/__init__.py +5 -0
  7. gitflow_analytics/security/reports/security_report.py +358 -0
  8. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/METADATA +2 -4
  9. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/RECORD +13 -24
  10. gitflow_analytics/tui/__init__.py +0 -5
  11. gitflow_analytics/tui/app.py +0 -726
  12. gitflow_analytics/tui/progress_adapter.py +0 -313
  13. gitflow_analytics/tui/screens/__init__.py +0 -8
  14. gitflow_analytics/tui/screens/analysis_progress_screen.py +0 -857
  15. gitflow_analytics/tui/screens/configuration_screen.py +0 -523
  16. gitflow_analytics/tui/screens/loading_screen.py +0 -348
  17. gitflow_analytics/tui/screens/main_screen.py +0 -321
  18. gitflow_analytics/tui/screens/results_screen.py +0 -735
  19. gitflow_analytics/tui/widgets/__init__.py +0 -7
  20. gitflow_analytics/tui/widgets/data_table.py +0 -255
  21. gitflow_analytics/tui/widgets/export_modal.py +0 -301
  22. gitflow_analytics/tui/widgets/progress_widget.py +0 -187
  23. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/WHEEL +0 -0
  24. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/entry_points.txt +0 -0
  25. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/licenses/LICENSE +0 -0
  26. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/top_level.txt +0 -0
@@ -1,857 +0,0 @@
1
- """Analysis progress screen for GitFlow Analytics TUI."""
2
-
3
- import asyncio
4
- import time
5
- from concurrent.futures import ThreadPoolExecutor
6
- from datetime import datetime, timedelta, timezone
7
- from pathlib import Path
8
- from typing import Any, Optional
9
-
10
- from textual.binding import Binding
11
- from textual.containers import Container, Vertical
12
- from textual.screen import Screen
13
- from textual.widgets import Footer, Header, Label, Log, Static
14
-
15
- from gitflow_analytics.config import Config
16
- from gitflow_analytics.core import progress as core_progress
17
- from gitflow_analytics.core.analyzer import GitAnalyzer
18
- from gitflow_analytics.core.cache import GitAnalysisCache
19
- from gitflow_analytics.core.identity import DeveloperIdentityResolver
20
- from gitflow_analytics.integrations.orchestrator import IntegrationOrchestrator
21
-
22
- from ..progress_adapter import TUIProgressService
23
- from ..widgets.progress_widget import AnalysisProgressWidget
24
-
25
-
26
- class AnalysisProgressScreen(Screen):
27
- """
28
- Screen showing real-time analysis progress with detailed status updates.
29
-
30
- WHY: Long-running analysis operations require comprehensive progress feedback
31
- to keep users informed and allow them to monitor the process. This screen
32
- provides real-time updates on all phases of analysis.
33
-
34
- DESIGN DECISION: Uses multiple progress widgets to show different phases
35
- independently, allowing users to understand which part of the analysis is
36
- currently running and estimated completion times for each phase.
37
- """
38
-
39
- BINDINGS = [
40
- Binding("ctrl+c", "cancel", "Cancel Analysis"),
41
- Binding("escape", "back", "Back to Main"),
42
- Binding("ctrl+l", "toggle_log", "Toggle Log"),
43
- ]
44
-
45
- def __init__(
46
- self,
47
- config: Config,
48
- weeks: int = 12,
49
- enable_qualitative: bool = True,
50
- *,
51
- name: Optional[str] = None,
52
- id: Optional[str] = None,
53
- ) -> None:
54
- super().__init__(name=name, id=id)
55
- self.config = config
56
- self.weeks = weeks
57
- self.enable_qualitative = enable_qualitative
58
- self.analysis_task: Optional[asyncio.Task] = None
59
- self.analysis_results = {}
60
- self.start_time = time.time()
61
- self.progress_service = None # Will be initialized on mount
62
- self.executor: Optional[ThreadPoolExecutor] = None # Managed executor for cleanup
63
-
64
- def compose(self):
65
- """Compose the analysis progress screen."""
66
- yield Header()
67
-
68
- with Container(id="progress-container"):
69
- yield Label("GitFlow Analytics - Analysis in Progress", classes="screen-title")
70
-
71
- # Progress panels for different phases
72
- with Vertical(id="progress-panels"):
73
- yield AnalysisProgressWidget("Overall Progress", total=100.0, id="overall-progress")
74
-
75
- yield AnalysisProgressWidget("Repository Analysis", total=100.0, id="repo-progress")
76
-
77
- yield AnalysisProgressWidget(
78
- "Integration Data", total=100.0, id="integration-progress"
79
- )
80
-
81
- if self.enable_qualitative:
82
- yield AnalysisProgressWidget(
83
- "Qualitative Analysis", total=100.0, id="qual-progress"
84
- )
85
-
86
- # Live statistics panel
87
- with Container(classes="stats-panel"):
88
- yield Label("Live Statistics", classes="panel-title")
89
- yield Static("No statistics yet...", id="live-stats")
90
-
91
- # Analysis log
92
- with Container(classes="log-panel"):
93
- yield Label("Analysis Log", classes="panel-title")
94
- yield Log(auto_scroll=True, id="analysis-log")
95
-
96
- yield Footer()
97
-
98
- def on_mount(self) -> None:
99
- """Start analysis when screen mounts."""
100
- # Initialize progress service for TUI
101
- self.progress_service = TUIProgressService(asyncio.get_event_loop())
102
- self.analysis_task = asyncio.create_task(self._run_analysis_wrapper())
103
-
104
- def on_unmount(self) -> None:
105
- """Cleanup when screen unmounts."""
106
- # Cancel the analysis task if it's still running
107
- if self.analysis_task and not self.analysis_task.done():
108
- self.analysis_task.cancel()
109
- # Don't wait for cancellation to complete to avoid blocking
110
-
111
- # Shutdown the executor to cleanup threads immediately
112
- if self.executor:
113
- self.executor.shutdown(wait=False)
114
- self.executor = None
115
-
116
- async def _run_analysis_wrapper(self) -> None:
117
- """Wrapper for analysis that handles cancellation gracefully."""
118
- try:
119
- await self._run_analysis()
120
- except asyncio.CancelledError:
121
- # Silently handle cancellation - this is expected during shutdown
122
- pass
123
- except Exception as e:
124
- # Log unexpected errors if the app is still running
125
- if self.app and self.app.is_running:
126
- try:
127
- log = self.query_one("#analysis-log", Log)
128
- log.write_line(f"❌ Unexpected error: {e}")
129
- except Exception:
130
- pass
131
-
132
- async def _run_analysis(self) -> None:
133
- """
134
- Run the complete analysis pipeline with progress updates.
135
-
136
- WHY: Implements the full analysis workflow with detailed progress tracking
137
- and error handling, ensuring users receive comprehensive feedback about
138
- the analysis process.
139
- """
140
- log = self.query_one("#analysis-log", Log)
141
- overall_progress = self.query_one("#overall-progress", AnalysisProgressWidget)
142
-
143
- try:
144
- log.write_line("🚀 Starting GitFlow Analytics...")
145
-
146
- # Phase 1: Initialize components (10%)
147
- overall_progress.update_progress(5, "Initializing components...")
148
- await self._initialize_components(log)
149
- overall_progress.update_progress(10, "Components initialized")
150
-
151
- # Phase 2: Repository discovery (20%)
152
- overall_progress.update_progress(10, "Discovering repositories...")
153
- repositories = await self._discover_repositories(log)
154
- overall_progress.update_progress(20, f"Found {len(repositories)} repositories")
155
-
156
- # Phase 3: Repository analysis (50%)
157
- overall_progress.update_progress(20, "Analyzing repositories...")
158
- commits, prs = await self._analyze_repositories(repositories, log)
159
- overall_progress.update_progress(50, f"Analyzed {len(commits)} commits")
160
-
161
- # Phase 4: Integration enrichment (70%)
162
- overall_progress.update_progress(50, "Enriching with integration data...")
163
- await self._enrich_with_integrations(repositories, commits, log)
164
- overall_progress.update_progress(70, "Integration data complete")
165
-
166
- # Phase 5: Identity resolution (80%)
167
- overall_progress.update_progress(70, "Resolving developer identities...")
168
- developer_stats = await self._resolve_identities(commits, log)
169
- overall_progress.update_progress(80, f"Identified {len(developer_stats)} developers")
170
-
171
- # Phase 6: Qualitative analysis (95%)
172
- if self.enable_qualitative:
173
- overall_progress.update_progress(80, "Running qualitative analysis...")
174
- await self._run_qualitative_analysis(commits, log)
175
- overall_progress.update_progress(95, "Qualitative analysis complete")
176
-
177
- # Phase 7: Finalization (100%)
178
- overall_progress.update_progress(95, "Finalizing results...")
179
- self.analysis_results = {
180
- "commits": commits,
181
- "prs": prs,
182
- "developers": developer_stats,
183
- "repositories": repositories,
184
- }
185
-
186
- overall_progress.complete("Analysis complete!")
187
-
188
- total_time = time.time() - self.start_time
189
- log.write_line(f"🎉 Analysis completed in {total_time:.1f} seconds!")
190
- log.write_line(f" - Total commits: {len(commits):,}")
191
- log.write_line(f" - Total PRs: {len(prs):,}")
192
- log.write_line(f" - Active developers: {len(developer_stats):,}")
193
-
194
- # Switch to results screen after brief pause
195
- await asyncio.sleep(2)
196
- from .results_screen import ResultsScreen
197
-
198
- self.app.push_screen(
199
- ResultsScreen(
200
- commits=commits, prs=prs, developers=developer_stats, config=self.config
201
- )
202
- )
203
-
204
- except asyncio.CancelledError:
205
- # Check if the app is still running before updating UI
206
- if self.app and self.app.is_running:
207
- try:
208
- log.write_line("❌ Analysis cancelled by user")
209
- overall_progress.update_progress(0, "Cancelled")
210
- except Exception:
211
- # Silently ignore if we can't update the UI
212
- pass
213
- # Re-raise for the wrapper to handle
214
- raise
215
- except Exception as e:
216
- # Check if the app is still running before updating UI
217
- if self.app and self.app.is_running:
218
- try:
219
- log.write_line(f"❌ Analysis failed: {e}")
220
- overall_progress.update_progress(0, f"Error: {str(e)[:50]}...")
221
- self.notify(f"Analysis failed: {e}", severity="error")
222
- except Exception:
223
- # Silently ignore if we can't update the UI
224
- pass
225
-
226
- async def _initialize_components(self, log: Log) -> None:
227
- """Initialize analysis components."""
228
- log.write_line("📋 Initializing cache...")
229
-
230
- self.cache = GitAnalysisCache(
231
- self.config.cache.directory, ttl_hours=self.config.cache.ttl_hours
232
- )
233
-
234
- log.write_line("👥 Initializing identity resolver...")
235
- self.identity_resolver = DeveloperIdentityResolver(
236
- self.config.cache.directory / "identities.db",
237
- similarity_threshold=self.config.analysis.similarity_threshold,
238
- manual_mappings=self.config.analysis.manual_identity_mappings,
239
- )
240
-
241
- log.write_line("🔍 Initializing analyzer...")
242
-
243
- # Enable branch analysis with progress logging for TUI
244
- branch_analysis_config = {
245
- "enable_progress_logging": True,
246
- "strategy": "all",
247
- }
248
-
249
- self.analyzer = GitAnalyzer(
250
- self.cache,
251
- branch_mapping_rules=self.config.analysis.branch_mapping_rules,
252
- allowed_ticket_platforms=getattr(self.config.analysis, "ticket_platforms", None),
253
- exclude_paths=self.config.analysis.exclude_paths,
254
- story_point_patterns=self.config.analysis.story_point_patterns,
255
- branch_analysis_config=branch_analysis_config,
256
- )
257
-
258
- log.write_line("🔗 Initializing integrations...")
259
- self.orchestrator = IntegrationOrchestrator(self.config, self.cache)
260
-
261
- # Check if we have pre-loaded NLP engine from startup
262
- if hasattr(self.app, "get_nlp_engine") and self.app.get_nlp_engine():
263
- log.write_line("✅ NLP engine already loaded from startup")
264
- elif self.enable_qualitative:
265
- log.write_line("⚠️ NLP engine will be loaded during qualitative analysis phase")
266
-
267
- # Small delay to show progress
268
- await asyncio.sleep(0.5)
269
-
270
- async def _discover_repositories(self, log: Log) -> list:
271
- """Discover repositories to analyze."""
272
- repositories = self.config.repositories
273
-
274
- if self.config.github.organization and not repositories:
275
- log.write_line(
276
- f"🔍 Discovering repositories from organization: {self.config.github.organization}"
277
- )
278
-
279
- try:
280
- # Use config directory for cloned repos
281
- config_dir = Path.cwd() # TODO: Get actual config directory
282
- repos_dir = config_dir / "repos"
283
-
284
- discovered_repos = self.config.discover_organization_repositories(
285
- clone_base_path=repos_dir
286
- )
287
- repositories = discovered_repos
288
-
289
- for repo in repositories:
290
- log.write_line(f" 📁 {repo.name} ({repo.github_repo})")
291
-
292
- except Exception as e:
293
- log.write_line(f" ❌ Repository discovery failed: {e}")
294
- raise
295
-
296
- await asyncio.sleep(0.5) # Brief pause for UI updates
297
- return repositories
298
-
299
- async def _analyze_repositories(self, repositories: list, log: Log) -> tuple:
300
- """Analyze all repositories and return commits and PRs."""
301
- # Import progress module at the top of the function
302
-
303
- repo_progress = self.query_one("#repo-progress", AnalysisProgressWidget)
304
- overall_progress = self.query_one("#overall-progress", AnalysisProgressWidget)
305
-
306
- all_commits = []
307
- all_prs = []
308
-
309
- # Analysis period (timezone-aware to match commit timestamps)
310
- end_date = datetime.now(timezone.utc)
311
- start_date = end_date - timedelta(weeks=self.weeks)
312
-
313
- # Create progress adapter for repository analysis
314
- repo_adapter = self.progress_service.create_adapter("repo", repo_progress)
315
-
316
- # Set initial stats for the adapter
317
- repo_adapter.processing_stats["total"] = len(repositories)
318
-
319
- # Temporarily replace the global progress service with our adapter
320
- original_progress_service = core_progress._progress_service
321
- core_progress._progress_service = repo_adapter
322
-
323
- total_repos = len(repositories)
324
-
325
- # Clone repositories that don't exist locally first
326
- for repo_config in repositories:
327
- if not repo_config.path.exists() and repo_config.github_repo:
328
- log.write_line(f" 📥 Cloning {repo_config.github_repo}...")
329
- await self._clone_repository(repo_config, log)
330
-
331
- # Check if we should use async processing (for multiple repositories)
332
- # We use async processing for 2+ repositories to keep the UI responsive
333
- use_async = len(repositories) > 1
334
-
335
- if use_async:
336
- log.write_line(f"🚀 Starting async analysis of {len(repositories)} repositories...")
337
-
338
- # Import data fetcher for parallel processing
339
- from gitflow_analytics.core.data_fetcher import GitDataFetcher
340
- from gitflow_analytics.tui.progress_adapter import TUIProgressAdapter
341
-
342
- # Create and set up progress adapter for parallel processing
343
- tui_progress_adapter = TUIProgressAdapter(repo_progress)
344
- tui_progress_adapter.set_event_loop(asyncio.get_event_loop())
345
-
346
- # Replace the global progress service so parallel processing can use it
347
- # We'll restore the original one after processing
348
- core_progress._progress_service = tui_progress_adapter
349
-
350
- # Create data fetcher
351
- # Use skip_remote_fetch=True when analyzing already-cloned repositories
352
- # to avoid authentication issues with expired tokens
353
- data_fetcher = GitDataFetcher(cache=self.cache, skip_remote_fetch=True)
354
-
355
- # Prepare repository configurations for parallel processing
356
- repo_configs = []
357
- for repo_config in repositories:
358
- repo_configs.append(
359
- {
360
- "path": str(repo_config.path),
361
- "project_key": repo_config.project_key or repo_config.name,
362
- "branch_patterns": [repo_config.branch] if repo_config.branch else None,
363
- }
364
- )
365
-
366
- # Run parallel processing in executor to avoid blocking
367
- loop = asyncio.get_event_loop()
368
-
369
- # Update overall progress
370
- overall_progress.update_progress(25, "Running parallel repository analysis...")
371
-
372
- try:
373
- # Process repositories asynchronously with yielding for UI updates
374
- parallel_results = await self._process_repositories_async(
375
- data_fetcher,
376
- repo_configs,
377
- start_date,
378
- end_date,
379
- repo_progress,
380
- overall_progress,
381
- log,
382
- )
383
-
384
- # Process results
385
- for project_key, result in parallel_results["results"].items():
386
- if result and "commits" in result:
387
- commits_data = result["commits"]
388
- # Add project key and resolve identities
389
- for commit in commits_data:
390
- commit["project_key"] = project_key
391
- commit["canonical_id"] = self.identity_resolver.resolve_developer(
392
- commit["author_name"], commit["author_email"]
393
- )
394
- all_commits.extend(commits_data)
395
- log.write_line(f" ✅ {project_key}: {len(commits_data)} commits")
396
-
397
- # Log final statistics
398
- stats = parallel_results.get("statistics", {})
399
- log.write_line("\n📊 Analysis Statistics:")
400
- log.write_line(f" Total: {stats.get('total', 0)} repositories")
401
- log.write_line(f" Success: {stats.get('success', 0)} (have commits)")
402
- log.write_line(
403
- f" No Commits: {stats.get('no_commits', 0)} (no activity in period)"
404
- )
405
- log.write_line(f" Failed: {stats.get('failed', 0)} (processing errors)")
406
- log.write_line(f" Timeout: {stats.get('timeout', 0)}")
407
-
408
- except Exception as e:
409
- log.write_line(f" ❌ Async processing failed: {e}")
410
- log.write_line(" Falling back to sequential processing...")
411
- use_async = False
412
- finally:
413
- # Restore original progress service
414
- core_progress._progress_service = original_progress_service
415
-
416
- # Sequential processing fallback or for single repository
417
- if not use_async:
418
- # Ensure we have an executor for sequential processing
419
- if not self.executor:
420
- self.executor = ThreadPoolExecutor(max_workers=1)
421
-
422
- for i, repo_config in enumerate(repositories):
423
- # Update overall progress based on repository completion
424
- overall_pct = 20 + ((i / total_repos) * 30) # 20-50% range for repo analysis
425
- overall_progress.update_progress(
426
- overall_pct, f"Analyzing repositories ({i+1}/{total_repos})..."
427
- )
428
-
429
- repo_progress.update_progress(0, f"Analyzing {repo_config.name}...")
430
-
431
- log.write_line(f"📁 Analyzing {repo_config.name}...")
432
-
433
- try:
434
- log.write_line(f" ⏳ Starting analysis of {repo_config.name}...")
435
-
436
- # Run repository analysis in a thread to avoid blocking
437
- loop = asyncio.get_event_loop()
438
- commits = await loop.run_in_executor(
439
- (
440
- self.executor if self.executor else None
441
- ), # Use managed executor if available
442
- self.analyzer.analyze_repository,
443
- repo_config.path,
444
- start_date,
445
- repo_config.branch,
446
- )
447
-
448
- log.write_line(f" ✓ Analysis complete for {repo_config.name}")
449
-
450
- # Add project key and resolve identities
451
- for commit in commits:
452
- commit["project_key"] = repo_config.project_key or commit.get(
453
- "inferred_project", "UNKNOWN"
454
- )
455
- commit["canonical_id"] = self.identity_resolver.resolve_developer(
456
- commit["author_name"], commit["author_email"]
457
- )
458
-
459
- all_commits.extend(commits)
460
- log.write_line(f" ✅ Found {len(commits)} commits")
461
-
462
- # Update live stats
463
- await self._update_live_stats(
464
- {
465
- "repositories_analyzed": i + 1,
466
- "total_repositories": len(repositories),
467
- "total_commits": len(all_commits),
468
- "current_repo": repo_config.name,
469
- }
470
- )
471
-
472
- # Small delay to allow UI updates
473
- await asyncio.sleep(0.05) # Reduced delay for more responsive updates
474
-
475
- except Exception as e:
476
- log.write_line(f" ❌ Error analyzing {repo_config.name}: {e}")
477
- continue
478
-
479
- # Restore original progress service
480
- core_progress._progress_service = original_progress_service
481
-
482
- repo_progress.complete(f"Completed {len(repositories)} repositories")
483
- overall_progress.update_progress(50, f"Analyzed {len(all_commits)} commits")
484
- return all_commits, all_prs
485
-
486
- async def _enrich_with_integrations(self, repositories: list, commits: list, log: Log) -> None:
487
- """Enrich data with integration sources."""
488
- integration_progress = self.query_one("#integration-progress", AnalysisProgressWidget)
489
-
490
- end_date = datetime.now(timezone.utc)
491
- start_date = end_date - timedelta(weeks=self.weeks)
492
-
493
- for i, repo_config in enumerate(repositories):
494
- progress = (i / len(repositories)) * 100
495
- integration_progress.update_progress(progress, f"Enriching {repo_config.name}...")
496
-
497
- try:
498
- # Get repository commits for this repo
499
- repo_commits = [c for c in commits if c.get("repository") == repo_config.name]
500
-
501
- enrichment = self.orchestrator.enrich_repository_data(
502
- repo_config, repo_commits, start_date
503
- )
504
-
505
- if enrichment.get("prs"):
506
- log.write_line(
507
- f" ✅ Found {len(enrichment['prs'])} pull requests for {repo_config.name}"
508
- )
509
-
510
- await asyncio.sleep(0.1)
511
-
512
- except Exception as e:
513
- log.write_line(f" ⚠️ Integration enrichment failed for {repo_config.name}: {e}")
514
- continue
515
-
516
- integration_progress.complete("Integration enrichment complete")
517
-
518
- async def _resolve_identities(self, commits: list, log: Log) -> list:
519
- """Resolve developer identities and return statistics."""
520
- log.write_line("👥 Updating developer statistics...")
521
-
522
- # Update commit statistics
523
- self.identity_resolver.update_commit_stats(commits)
524
- developer_stats = self.identity_resolver.get_developer_stats()
525
-
526
- log.write_line(f" ✅ Resolved {len(developer_stats)} unique developer identities")
527
-
528
- # Show top contributors
529
- top_devs = sorted(developer_stats, key=lambda d: d["total_commits"], reverse=True)[:5]
530
- for dev in top_devs:
531
- log.write_line(f" • {dev['primary_name']}: {dev['total_commits']} commits")
532
-
533
- await asyncio.sleep(0.5)
534
- return developer_stats
535
-
536
- async def _run_qualitative_analysis(self, commits: list, log: Log) -> None:
537
- """Run qualitative analysis if enabled."""
538
- if not self.enable_qualitative:
539
- return
540
-
541
- qual_progress = self.query_one("#qual-progress", AnalysisProgressWidget)
542
-
543
- try:
544
- log.write_line("🧠 Starting qualitative analysis...")
545
-
546
- # Check if NLP engine is pre-loaded from startup
547
- nlp_engine = None
548
- if hasattr(self.app, "get_nlp_engine"):
549
- nlp_engine = self.app.get_nlp_engine()
550
-
551
- if nlp_engine:
552
- log.write_line(" ✅ Using pre-loaded NLP engine")
553
- qual_processor = None # We'll use the NLP engine directly
554
- else:
555
- log.write_line(" ⏳ Initializing qualitative processor...")
556
- # Import qualitative processor
557
- from gitflow_analytics.qualitative.core.processor import QualitativeProcessor
558
-
559
- qual_processor = QualitativeProcessor(self.config.qualitative)
560
-
561
- # Validate setup
562
- is_valid, issues = qual_processor.validate_setup()
563
- if not is_valid:
564
- log.write_line(" ⚠️ Qualitative analysis setup issues:")
565
- for issue in issues:
566
- log.write_line(f" - {issue}")
567
- return
568
-
569
- # Process commits in batches
570
- batch_size = 100
571
- total_batches = (len(commits) + batch_size - 1) // batch_size
572
-
573
- for batch_idx in range(total_batches):
574
- start_idx = batch_idx * batch_size
575
- end_idx = min(start_idx + batch_size, len(commits))
576
- batch = commits[start_idx:end_idx]
577
-
578
- progress = (batch_idx / total_batches) * 100
579
- qual_progress.update_progress(
580
- progress, f"Processing batch {batch_idx + 1}/{total_batches}..."
581
- )
582
-
583
- # Convert to qualitative format
584
- qual_batch = []
585
- for commit in batch:
586
- qual_commit = {
587
- "hash": commit.get("hash"),
588
- "message": commit.get("message"),
589
- "author_name": commit.get("author_name"),
590
- "author_email": commit.get("author_email"),
591
- "timestamp": commit.get("timestamp"),
592
- "files_changed": commit.get("files_changed", []),
593
- "insertions": commit.get("insertions", 0),
594
- "deletions": commit.get("deletions", 0),
595
- "branch": commit.get("branch", "main"),
596
- }
597
- qual_batch.append(qual_commit)
598
-
599
- # Process batch using pre-loaded NLP engine or processor
600
- if nlp_engine:
601
- # Use the pre-loaded NLP engine directly
602
- results = nlp_engine.process_batch(qual_batch)
603
- else:
604
- # Use the qualitative processor
605
- results = qual_processor.process_commits(qual_batch, show_progress=False)
606
-
607
- # Update original commits with qualitative data
608
- for original, enhanced in zip(batch, results):
609
- if hasattr(enhanced, "change_type"):
610
- original["change_type"] = enhanced.change_type
611
- original["business_domain"] = enhanced.business_domain
612
- original["risk_level"] = enhanced.risk_level
613
- original["confidence_score"] = enhanced.confidence_score
614
-
615
- await asyncio.sleep(0.1) # Allow UI updates
616
-
617
- qual_progress.complete("Qualitative analysis complete")
618
- log.write_line(" ✅ Qualitative analysis completed")
619
-
620
- except ImportError:
621
- log.write_line(" ❌ Qualitative analysis dependencies not available")
622
- qual_progress.update_progress(0, "Dependencies missing")
623
- except Exception as e:
624
- log.write_line(f" ❌ Qualitative analysis failed: {e}")
625
- qual_progress.update_progress(0, f"Error: {str(e)[:30]}...")
626
-
627
- async def _process_repositories_async(
628
- self,
629
- data_fetcher,
630
- repo_configs: list,
631
- start_date: datetime,
632
- end_date: datetime,
633
- repo_progress: AnalysisProgressWidget,
634
- overall_progress: AnalysisProgressWidget,
635
- log: Log,
636
- ) -> dict:
637
- """
638
- Process repositories asynchronously with proper yielding for UI updates.
639
-
640
- This method processes repositories one at a time but yields control back
641
- to the event loop between each repository to allow UI updates.
642
- """
643
- results = {
644
- "results": {},
645
- "statistics": {
646
- "total": len(repo_configs),
647
- "processed": 0,
648
- "success": 0,
649
- "no_commits": 0,
650
- "failed": 0,
651
- "timeout": 0,
652
- },
653
- }
654
-
655
- stats = results["statistics"]
656
- loop = asyncio.get_event_loop()
657
-
658
- # Create a managed executor for this analysis
659
- if not self.executor:
660
- self.executor = ThreadPoolExecutor(max_workers=1)
661
-
662
- for i, repo_config in enumerate(repo_configs):
663
- project_key = repo_config["project_key"]
664
-
665
- # Update progress before processing
666
- percentage = (i / stats["total"]) * 100
667
- repo_progress.update_progress(
668
- percentage, f"Processing {project_key} ({i+1}/{stats['total']})..."
669
- )
670
-
671
- # Update overall progress
672
- overall_percentage = 25 + ((i / stats["total"]) * 25) # 25-50% range
673
- overall_progress.update_progress(
674
- overall_percentage, f"Analyzing repository {i+1}/{stats['total']}: {project_key}"
675
- )
676
-
677
- log.write_line(f"🔍 Processing {project_key} ({i+1}/{stats['total']})...")
678
-
679
- try:
680
- # Run the actual repository processing in a thread to avoid blocking
681
- # but await it properly so we can yield between repositories
682
- result = await loop.run_in_executor(
683
- self.executor, # Use managed executor instead of default
684
- self._process_single_repository_sync,
685
- data_fetcher,
686
- repo_config,
687
- self.weeks,
688
- start_date,
689
- end_date,
690
- )
691
-
692
- # Check for commits - data fetcher returns 'daily_commits' not 'commits'
693
- if result:
694
- # Extract commits from daily_commits structure
695
- daily_commits = result.get("daily_commits", {})
696
- total_commits = result.get("stats", {}).get("total_commits", 0)
697
-
698
- # Convert daily_commits to flat commits list
699
- commits = []
700
- for _date_str, day_commits in daily_commits.items():
701
- commits.extend(day_commits)
702
-
703
- # Add flattened commits to result for compatibility
704
- result["commits"] = commits
705
-
706
- if total_commits > 0 or commits:
707
- results["results"][project_key] = result
708
- stats["success"] += 1
709
- log.write_line(f" ✅ {project_key}: {total_commits} commits")
710
- else:
711
- stats["no_commits"] += 1
712
- log.write_line(f" ⏸️ {project_key}: No commits in analysis period")
713
- else:
714
- stats["failed"] += 1
715
- log.write_line(f" ❌ {project_key}: Failed to process")
716
-
717
- except Exception as e:
718
- stats["failed"] += 1
719
- log.write_line(f" ❌ {project_key}: Error - {str(e)[:50]}...")
720
-
721
- stats["processed"] += 1
722
-
723
- # Update progress after processing
724
- percentage = ((i + 1) / stats["total"]) * 100
725
- repo_progress.update_progress(
726
- percentage, f"Completed {project_key} ({i+1}/{stats['total']})"
727
- )
728
-
729
- # Yield control to event loop for UI updates
730
- # This is the key to keeping the UI responsive
731
- await asyncio.sleep(0.01)
732
-
733
- # Also update live stats
734
- await self._update_live_stats(
735
- {
736
- "repositories_analyzed": stats["processed"],
737
- "total_repositories": stats["total"],
738
- "successful": stats["success"],
739
- "no_commits": stats["no_commits"],
740
- "failed": stats["failed"],
741
- "current_repo": project_key if i < len(repo_configs) - 1 else "Complete",
742
- }
743
- )
744
-
745
- # Final progress update
746
- repo_progress.complete(f"Processed {stats['total']} repositories")
747
-
748
- # Cleanup executor after processing
749
- if self.executor:
750
- self.executor.shutdown(wait=False)
751
- self.executor = None
752
-
753
- return results
754
-
755
- def _process_single_repository_sync(
756
- self,
757
- data_fetcher,
758
- repo_config: dict,
759
- weeks_back: int,
760
- start_date: datetime,
761
- end_date: datetime,
762
- ) -> Optional[dict]:
763
- """
764
- Synchronous wrapper for processing a single repository.
765
-
766
- This runs in a thread executor to avoid blocking the event loop.
767
- """
768
- try:
769
- # Process the repository using data fetcher
770
- result = data_fetcher.fetch_repository_data(
771
- repo_path=Path(repo_config["path"]),
772
- project_key=repo_config["project_key"],
773
- weeks_back=weeks_back,
774
- branch_patterns=repo_config.get("branch_patterns"),
775
- jira_integration=None,
776
- progress_callback=None,
777
- start_date=start_date,
778
- end_date=end_date,
779
- )
780
- return result
781
- except Exception as e:
782
- import logging
783
-
784
- logging.getLogger(__name__).error(f"Error processing {repo_config['project_key']}: {e}")
785
- return None
786
-
787
- async def _clone_repository(self, repo_config, log: Log) -> None:
788
- """Clone repository if needed."""
789
- try:
790
- import git
791
-
792
- repo_config.path.parent.mkdir(parents=True, exist_ok=True)
793
-
794
- clone_url = f"https://github.com/{repo_config.github_repo}.git"
795
- if self.config.github.token:
796
- clone_url = (
797
- f"https://{self.config.github.token}@github.com/{repo_config.github_repo}.git"
798
- )
799
-
800
- # Try to clone with specified branch, fall back to default if it fails
801
- try:
802
- if repo_config.branch:
803
- git.Repo.clone_from(clone_url, repo_config.path, branch=repo_config.branch)
804
- else:
805
- git.Repo.clone_from(clone_url, repo_config.path)
806
- except git.GitCommandError as e:
807
- if repo_config.branch and "Remote branch" in str(e) and "not found" in str(e):
808
- # Branch doesn't exist, try cloning without specifying branch
809
- log.write_line(
810
- f" ⚠️ Branch '{repo_config.branch}' not found, using repository default"
811
- )
812
- git.Repo.clone_from(clone_url, repo_config.path)
813
- else:
814
- raise
815
- log.write_line(f" ✅ Successfully cloned {repo_config.github_repo}")
816
-
817
- except Exception as e:
818
- log.write_line(f" ❌ Failed to clone {repo_config.github_repo}: {e}")
819
- raise
820
-
821
- async def _update_live_stats(self, stats: dict[str, Any]) -> None:
822
- """Update live statistics display."""
823
- try:
824
- stats_widget = self.query_one("#live-stats", Static)
825
-
826
- # Format stats for display
827
- stats_text = "\n".join(
828
- [f"• {key.replace('_', ' ').title()}: {value}" for key, value in stats.items()]
829
- )
830
- stats_widget.update(stats_text)
831
- except Exception:
832
- # Silently ignore if widget doesn't exist (e.g., in testing)
833
- pass
834
-
835
- def action_cancel(self) -> None:
836
- """Cancel the analysis."""
837
- if self.analysis_task and not self.analysis_task.done():
838
- self.analysis_task.cancel()
839
- # Give the task a moment to cancel cleanly
840
- asyncio.create_task(self._delayed_pop_screen())
841
- else:
842
- self.app.pop_screen()
843
-
844
- async def _delayed_pop_screen(self) -> None:
845
- """Pop screen after a brief delay to allow cancellation to complete."""
846
- await asyncio.sleep(0.1)
847
- if self.app and self.app.is_running:
848
- self.app.pop_screen()
849
-
850
- def action_back(self) -> None:
851
- """Go back to main screen."""
852
- self.action_cancel()
853
-
854
- def action_toggle_log(self) -> None:
855
- """Toggle log panel visibility."""
856
- log_panel = self.query_one(".log-panel")
857
- log_panel.set_class(not log_panel.has_class("hidden"), "hidden")