gitflow-analytics 3.6.2__py3-none-any.whl → 3.7.4__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/__init__.py +8 -12
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/cli.py +323 -203
- gitflow_analytics/cli_wizards/install_wizard.py +5 -5
- gitflow_analytics/config/repository.py +9 -1
- gitflow_analytics/config/schema.py +39 -0
- gitflow_analytics/identity_llm/analysis_pass.py +7 -2
- gitflow_analytics/models/database.py +229 -8
- {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.4.dist-info}/METADATA +2 -4
- {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.4.dist-info}/RECORD +14 -27
- gitflow_analytics/tui/__init__.py +0 -5
- gitflow_analytics/tui/app.py +0 -726
- gitflow_analytics/tui/progress_adapter.py +0 -313
- gitflow_analytics/tui/screens/__init__.py +0 -8
- gitflow_analytics/tui/screens/analysis_progress_screen.py +0 -857
- gitflow_analytics/tui/screens/configuration_screen.py +0 -523
- gitflow_analytics/tui/screens/loading_screen.py +0 -348
- gitflow_analytics/tui/screens/main_screen.py +0 -321
- gitflow_analytics/tui/screens/results_screen.py +0 -735
- gitflow_analytics/tui/widgets/__init__.py +0 -7
- gitflow_analytics/tui/widgets/data_table.py +0 -255
- gitflow_analytics/tui/widgets/export_modal.py +0 -301
- gitflow_analytics/tui/widgets/progress_widget.py +0 -187
- {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.4.dist-info}/WHEEL +0 -0
- {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.4.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.4.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.4.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")
|