gitflow-analytics 1.0.0__py3-none-any.whl → 1.0.3__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 (58) hide show
  1. gitflow_analytics/__init__.py +11 -9
  2. gitflow_analytics/_version.py +2 -2
  3. gitflow_analytics/cli.py +691 -243
  4. gitflow_analytics/cli_rich.py +353 -0
  5. gitflow_analytics/config.py +389 -96
  6. gitflow_analytics/core/analyzer.py +175 -78
  7. gitflow_analytics/core/branch_mapper.py +132 -132
  8. gitflow_analytics/core/cache.py +242 -173
  9. gitflow_analytics/core/identity.py +214 -178
  10. gitflow_analytics/extractors/base.py +13 -11
  11. gitflow_analytics/extractors/story_points.py +70 -59
  12. gitflow_analytics/extractors/tickets.py +111 -88
  13. gitflow_analytics/integrations/github_integration.py +91 -77
  14. gitflow_analytics/integrations/jira_integration.py +284 -0
  15. gitflow_analytics/integrations/orchestrator.py +99 -72
  16. gitflow_analytics/metrics/dora.py +183 -179
  17. gitflow_analytics/models/database.py +191 -54
  18. gitflow_analytics/qualitative/__init__.py +30 -0
  19. gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
  20. gitflow_analytics/qualitative/classifiers/change_type.py +468 -0
  21. gitflow_analytics/qualitative/classifiers/domain_classifier.py +399 -0
  22. gitflow_analytics/qualitative/classifiers/intent_analyzer.py +436 -0
  23. gitflow_analytics/qualitative/classifiers/risk_analyzer.py +412 -0
  24. gitflow_analytics/qualitative/core/__init__.py +13 -0
  25. gitflow_analytics/qualitative/core/llm_fallback.py +653 -0
  26. gitflow_analytics/qualitative/core/nlp_engine.py +373 -0
  27. gitflow_analytics/qualitative/core/pattern_cache.py +457 -0
  28. gitflow_analytics/qualitative/core/processor.py +540 -0
  29. gitflow_analytics/qualitative/models/__init__.py +25 -0
  30. gitflow_analytics/qualitative/models/schemas.py +272 -0
  31. gitflow_analytics/qualitative/utils/__init__.py +13 -0
  32. gitflow_analytics/qualitative/utils/batch_processor.py +326 -0
  33. gitflow_analytics/qualitative/utils/cost_tracker.py +343 -0
  34. gitflow_analytics/qualitative/utils/metrics.py +347 -0
  35. gitflow_analytics/qualitative/utils/text_processing.py +243 -0
  36. gitflow_analytics/reports/analytics_writer.py +25 -8
  37. gitflow_analytics/reports/csv_writer.py +60 -32
  38. gitflow_analytics/reports/narrative_writer.py +21 -15
  39. gitflow_analytics/tui/__init__.py +5 -0
  40. gitflow_analytics/tui/app.py +721 -0
  41. gitflow_analytics/tui/screens/__init__.py +8 -0
  42. gitflow_analytics/tui/screens/analysis_progress_screen.py +487 -0
  43. gitflow_analytics/tui/screens/configuration_screen.py +547 -0
  44. gitflow_analytics/tui/screens/loading_screen.py +358 -0
  45. gitflow_analytics/tui/screens/main_screen.py +304 -0
  46. gitflow_analytics/tui/screens/results_screen.py +698 -0
  47. gitflow_analytics/tui/widgets/__init__.py +7 -0
  48. gitflow_analytics/tui/widgets/data_table.py +257 -0
  49. gitflow_analytics/tui/widgets/export_modal.py +301 -0
  50. gitflow_analytics/tui/widgets/progress_widget.py +192 -0
  51. gitflow_analytics-1.0.3.dist-info/METADATA +490 -0
  52. gitflow_analytics-1.0.3.dist-info/RECORD +62 -0
  53. gitflow_analytics-1.0.0.dist-info/METADATA +0 -201
  54. gitflow_analytics-1.0.0.dist-info/RECORD +0 -30
  55. {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.3.dist-info}/WHEEL +0 -0
  56. {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.3.dist-info}/entry_points.txt +0 -0
  57. {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.3.dist-info}/licenses/LICENSE +0 -0
  58. {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,7 @@
1
+ """Custom widgets for the GitFlow Analytics TUI."""
2
+
3
+ from .progress_widget import AnalysisProgressWidget
4
+ from .data_table import EnhancedDataTable
5
+ from .export_modal import ExportModal
6
+
7
+ __all__ = ["AnalysisProgressWidget", "EnhancedDataTable", "ExportModal"]
@@ -0,0 +1,257 @@
1
+ """Enhanced data table widget for GitFlow Analytics TUI."""
2
+
3
+ from typing import Any, Dict, List, Optional, Union
4
+ from datetime import datetime
5
+
6
+ from textual.widgets import DataTable
7
+ from textual.reactive import reactive
8
+ from rich.text import Text
9
+
10
+
11
+ class EnhancedDataTable(DataTable):
12
+ """
13
+ Enhanced data table with sorting, filtering, and formatting capabilities.
14
+
15
+ WHY: The standard DataTable widget lacks features needed for displaying
16
+ complex analytics data like sorting, filtering, and intelligent formatting
17
+ of different data types (dates, numbers, etc.).
18
+
19
+ DESIGN DECISION: Extends DataTable rather than creating from scratch to
20
+ maintain compatibility with Textual's data table features while adding
21
+ the necessary enhancements for analytics display.
22
+ """
23
+
24
+ DEFAULT_CSS = """
25
+ EnhancedDataTable {
26
+ height: auto;
27
+ }
28
+
29
+ EnhancedDataTable > .datatable--header {
30
+ background: $primary 10%;
31
+ color: $primary;
32
+ text-style: bold;
33
+ }
34
+
35
+ EnhancedDataTable > .datatable--row-hover {
36
+ background: $accent 20%;
37
+ }
38
+
39
+ EnhancedDataTable > .datatable--row-cursor {
40
+ background: $primary 30%;
41
+ }
42
+ """
43
+
44
+ # Reactive attributes for dynamic updates
45
+ sort_column = reactive("")
46
+ sort_reverse = reactive(False)
47
+ filter_text = reactive("")
48
+
49
+ def __init__(
50
+ self,
51
+ data: Optional[List[Dict[str, Any]]] = None,
52
+ *,
53
+ name: Optional[str] = None,
54
+ id: Optional[str] = None,
55
+ classes: Optional[str] = None
56
+ ) -> None:
57
+ super().__init__(name=name, id=id, classes=classes)
58
+ self._raw_data = data or []
59
+ self._filtered_data = []
60
+ self._column_formatters = {}
61
+
62
+ def set_data(self, data: List[Dict[str, Any]]) -> None:
63
+ """
64
+ Set table data with automatic column detection and formatting.
65
+
66
+ WHY: Automatically handles different data types and formats them
67
+ appropriately for display, reducing the need for manual formatting
68
+ in calling code.
69
+ """
70
+ self._raw_data = data
71
+ if not data:
72
+ return
73
+
74
+ # Clear existing data
75
+ self.clear()
76
+
77
+ # Get columns from first row
78
+ columns = list(data[0].keys())
79
+
80
+ # Add columns with appropriate widths
81
+ for col in columns:
82
+ width = self._calculate_column_width(col, data)
83
+ self.add_column(self._format_column_header(col), width=width, key=col)
84
+
85
+ # Set up formatters based on data types
86
+ self._setup_formatters(data)
87
+
88
+ # Add data rows
89
+ self._apply_filter_and_sort()
90
+
91
+ def _calculate_column_width(self, column: str, data: List[Dict[str, Any]]) -> int:
92
+ """
93
+ Calculate appropriate column width based on content.
94
+
95
+ WHY: Dynamically sizes columns based on actual content to optimize
96
+ display space while ensuring all content is visible.
97
+ """
98
+ # Start with header width
99
+ max_width = len(self._format_column_header(column))
100
+
101
+ # Check sample of data for width
102
+ sample_size = min(50, len(data))
103
+ for row in data[:sample_size]:
104
+ value = row.get(column, "")
105
+ formatted_value = self._format_cell_value(column, value)
106
+ max_width = max(max_width, len(str(formatted_value)))
107
+
108
+ # Set reasonable bounds
109
+ return min(max(max_width + 2, 8), 50)
110
+
111
+ def _format_column_header(self, column: str) -> str:
112
+ """Format column header for display."""
113
+ # Convert snake_case to Title Case
114
+ return column.replace('_', ' ').title()
115
+
116
+ def _setup_formatters(self, data: List[Dict[str, Any]]) -> None:
117
+ """
118
+ Set up column formatters based on data types.
119
+
120
+ WHY: Automatically detects data types and applies appropriate formatting
121
+ (dates, numbers, percentages) to improve readability.
122
+ """
123
+ if not data:
124
+ return
125
+
126
+ sample_row = data[0]
127
+
128
+ for column, value in sample_row.items():
129
+ if isinstance(value, datetime):
130
+ self._column_formatters[column] = self._format_datetime
131
+ elif isinstance(value, (int, float)):
132
+ # Check if it looks like a percentage
133
+ if any('pct' in column.lower() or 'percent' in column.lower()
134
+ or 'rate' in column.lower() for _ in [column]):
135
+ self._column_formatters[column] = self._format_percentage
136
+ else:
137
+ self._column_formatters[column] = self._format_number
138
+ elif isinstance(value, str) and len(value) > 50:
139
+ self._column_formatters[column] = self._format_long_text
140
+
141
+ def _format_cell_value(self, column: str, value: Any) -> str:
142
+ """Format individual cell value."""
143
+ if value is None:
144
+ return ""
145
+
146
+ formatter = self._column_formatters.get(column, str)
147
+ return formatter(value)
148
+
149
+ def _format_datetime(self, value: datetime) -> str:
150
+ """Format datetime values."""
151
+ if isinstance(value, str):
152
+ try:
153
+ value = datetime.fromisoformat(value.replace('Z', '+00:00'))
154
+ except:
155
+ return str(value)
156
+ return value.strftime('%Y-%m-%d %H:%M')
157
+
158
+ def _format_number(self, value: Union[int, float]) -> str:
159
+ """Format numeric values."""
160
+ if isinstance(value, float):
161
+ if value >= 1000:
162
+ return f"{value:,.1f}"
163
+ else:
164
+ return f"{value:.2f}"
165
+ return f"{value:,}"
166
+
167
+ def _format_percentage(self, value: Union[int, float]) -> str:
168
+ """Format percentage values."""
169
+ return f"{value:.1f}%"
170
+
171
+ def _format_long_text(self, value: str) -> str:
172
+ """Format long text values."""
173
+ if len(value) > 47:
174
+ return value[:44] + "..."
175
+ return value
176
+
177
+ def _apply_filter_and_sort(self) -> None:
178
+ """
179
+ Apply current filter and sort settings to data.
180
+
181
+ WHY: Provides real-time filtering and sorting capabilities that are
182
+ essential for exploring large datasets in the analytics results.
183
+ """
184
+ # Start with all data
185
+ filtered_data = self._raw_data.copy()
186
+
187
+ # Apply filter if set
188
+ if self.filter_text:
189
+ filtered_data = [
190
+ row for row in filtered_data
191
+ if any(self.filter_text.lower() in str(value).lower()
192
+ for value in row.values())
193
+ ]
194
+
195
+ # Apply sort if set
196
+ if self.sort_column and self.sort_column in (filtered_data[0] if filtered_data else {}):
197
+ try:
198
+ filtered_data.sort(
199
+ key=lambda x: x.get(self.sort_column, ""),
200
+ reverse=self.sort_reverse
201
+ )
202
+ except TypeError:
203
+ # Handle mixed types by converting to string
204
+ filtered_data.sort(
205
+ key=lambda x: str(x.get(self.sort_column, "")),
206
+ reverse=self.sort_reverse
207
+ )
208
+
209
+ self._filtered_data = filtered_data
210
+
211
+ # Clear and repopulate table
212
+ self.clear(columns=False) # Keep columns, clear rows
213
+
214
+ for row in filtered_data:
215
+ formatted_row = [
216
+ self._format_cell_value(col, row.get(col, ""))
217
+ for col in row.keys()
218
+ ]
219
+ self.add_row(*formatted_row)
220
+
221
+ def sort_by_column(self, column: str, reverse: bool = False) -> None:
222
+ """Sort table by specified column."""
223
+ self.sort_column = column
224
+ self.sort_reverse = reverse
225
+ self._apply_filter_and_sort()
226
+
227
+ def filter_data(self, filter_text: str) -> None:
228
+ """Filter table data by text search."""
229
+ self.filter_text = filter_text
230
+ self._apply_filter_and_sort()
231
+
232
+ def export_to_csv(self, filename: str) -> None:
233
+ """
234
+ Export current filtered/sorted data to CSV.
235
+
236
+ WHY: Allows users to export the specific view they've filtered and
237
+ sorted, maintaining their exploration context.
238
+ """
239
+ import csv
240
+
241
+ if not self._filtered_data:
242
+ return
243
+
244
+ with open(filename, 'w', newline='', encoding='utf-8') as csvfile:
245
+ if self._filtered_data:
246
+ fieldnames = list(self._filtered_data[0].keys())
247
+ writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
248
+ writer.writeheader()
249
+ writer.writerows(self._filtered_data)
250
+
251
+ def get_row_count(self) -> int:
252
+ """Get number of rows currently displayed."""
253
+ return len(self._filtered_data)
254
+
255
+ def get_total_count(self) -> int:
256
+ """Get total number of rows in dataset."""
257
+ return len(self._raw_data)
@@ -0,0 +1,301 @@
1
+ """Export modal dialog for GitFlow Analytics TUI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional, Dict, Any, List, Callable
5
+
6
+ from textual.widgets import Button, Input, Label, Select, Switch, Static
7
+ from textual.containers import Container, Horizontal, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.binding import Binding
10
+ from textual.message import Message
11
+
12
+
13
+ class ExportModal(ModalScreen[Optional[Dict[str, Any]]]):
14
+ """
15
+ Modal dialog for configuring and executing data exports.
16
+
17
+ WHY: Provides a comprehensive export interface that allows users to
18
+ choose format, location, and export options without cluttering the
19
+ main interface. Modal design ensures focused interaction.
20
+
21
+ DESIGN DECISION: Returns export configuration as a dictionary rather
22
+ than executing export directly, allowing the calling code to handle
23
+ the actual export operation with proper error handling and progress feedback.
24
+ """
25
+
26
+ DEFAULT_CSS = """
27
+ ExportModal {
28
+ align: center middle;
29
+ }
30
+
31
+ #export-dialog {
32
+ background: $surface;
33
+ border: thick $primary;
34
+ width: 80;
35
+ height: 25;
36
+ padding: 1;
37
+ }
38
+
39
+ .modal-title {
40
+ text-align: center;
41
+ text-style: bold;
42
+ color: $primary;
43
+ margin-bottom: 1;
44
+ }
45
+
46
+ .section-title {
47
+ text-style: bold;
48
+ color: $secondary;
49
+ margin: 1 0;
50
+ }
51
+
52
+ .button-bar {
53
+ dock: bottom;
54
+ height: 3;
55
+ align: center middle;
56
+ }
57
+
58
+ .form-row {
59
+ height: 3;
60
+ margin: 1 0;
61
+ }
62
+
63
+ .form-label {
64
+ width: 20;
65
+ padding: 1 0;
66
+ }
67
+ """
68
+
69
+ BINDINGS = [
70
+ Binding("escape", "cancel", "Cancel"),
71
+ Binding("ctrl+s", "export", "Export"),
72
+ ]
73
+
74
+ class ExportRequested(Message):
75
+ """Message sent when export is requested."""
76
+
77
+ def __init__(self, config: Dict[str, Any]) -> None:
78
+ super().__init__()
79
+ self.config = config
80
+
81
+ def __init__(
82
+ self,
83
+ available_formats: Optional[List[str]] = None,
84
+ default_path: Optional[Path] = None,
85
+ data_info: Optional[Dict[str, Any]] = None
86
+ ) -> None:
87
+ super().__init__()
88
+ self.available_formats = available_formats or ["CSV", "JSON", "Markdown"]
89
+ self.default_path = default_path or Path("./reports")
90
+ self.data_info = data_info or {}
91
+
92
+ def compose(self):
93
+ """Compose the export modal dialog."""
94
+ with Container(id="export-dialog"):
95
+ yield Label("Export Data", classes="modal-title")
96
+
97
+ # Format selection
98
+ yield Label("Export Format:", classes="section-title")
99
+ format_options = [(fmt, fmt.lower()) for fmt in self.available_formats]
100
+ yield Select(format_options, value=format_options[0][1], id="format-select")
101
+
102
+ # File path
103
+ yield Label("Export Location:", classes="section-title")
104
+ with Horizontal(classes="form-row"):
105
+ yield Label("Directory:", classes="form-label")
106
+ yield Input(
107
+ value=str(self.default_path),
108
+ placeholder="Path to export directory",
109
+ id="path-input"
110
+ )
111
+
112
+ with Horizontal(classes="form-row"):
113
+ yield Label("Filename:", classes="form-label")
114
+ yield Input(
115
+ value=self._generate_default_filename(),
116
+ placeholder="Export filename",
117
+ id="filename-input"
118
+ )
119
+
120
+ # Export options
121
+ yield Label("Export Options:", classes="section-title")
122
+
123
+ with Horizontal(classes="form-row"):
124
+ yield Label("Include headers:", classes="form-label")
125
+ yield Switch(value=True, id="include-headers")
126
+
127
+ with Horizontal(classes="form-row"):
128
+ yield Label("Anonymize data:", classes="form-label")
129
+ yield Switch(value=False, id="anonymize-data")
130
+
131
+ # Data info
132
+ if self.data_info:
133
+ yield Label("Data Summary:", classes="section-title")
134
+ info_text = self._format_data_info()
135
+ yield Static(info_text, id="data-info")
136
+
137
+ # Button bar
138
+ with Horizontal(classes="button-bar"):
139
+ yield Button("Cancel", variant="default", id="cancel-btn")
140
+ yield Button("Export", variant="primary", id="export-btn")
141
+
142
+ def _generate_default_filename(self) -> str:
143
+ """
144
+ Generate default filename based on export format and current date.
145
+
146
+ WHY: Provides sensible defaults to reduce user input while ensuring
147
+ unique filenames that won't accidentally overwrite existing files.
148
+ """
149
+ from datetime import datetime
150
+
151
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
152
+ data_type = self.data_info.get('type', 'export')
153
+ return f"{data_type}_{timestamp}"
154
+
155
+ def _format_data_info(self) -> str:
156
+ """Format data information for display."""
157
+ info_lines = []
158
+
159
+ if 'row_count' in self.data_info:
160
+ info_lines.append(f"• Rows: {self.data_info['row_count']:,}")
161
+
162
+ if 'column_count' in self.data_info:
163
+ info_lines.append(f"• Columns: {self.data_info['column_count']}")
164
+
165
+ if 'date_range' in self.data_info:
166
+ info_lines.append(f"• Date range: {self.data_info['date_range']}")
167
+
168
+ if 'data_types' in self.data_info:
169
+ types_str = ", ".join(self.data_info['data_types'])
170
+ info_lines.append(f"• Data types: {types_str}")
171
+
172
+ return "\n".join(info_lines)
173
+
174
+ def on_button_pressed(self, event: Button.Pressed) -> None:
175
+ """Handle button press events."""
176
+ if event.button.id == "cancel-btn":
177
+ self.action_cancel()
178
+ elif event.button.id == "export-btn":
179
+ self.action_export()
180
+
181
+ def action_cancel(self) -> None:
182
+ """Cancel the export operation."""
183
+ self.dismiss(None)
184
+
185
+ def action_export(self) -> None:
186
+ """
187
+ Validate inputs and request export operation.
188
+
189
+ WHY: Performs comprehensive validation before submitting export
190
+ request to prevent errors and provide immediate feedback to users.
191
+ """
192
+ try:
193
+ # Collect form data
194
+ format_select = self.query_one("#format-select", Select)
195
+ path_input = self.query_one("#path-input", Input)
196
+ filename_input = self.query_one("#filename-input", Input)
197
+ include_headers = self.query_one("#include-headers", Switch)
198
+ anonymize_data = self.query_one("#anonymize-data", Switch)
199
+
200
+ # Validate inputs
201
+ export_path = Path(path_input.value.strip())
202
+ filename = filename_input.value.strip()
203
+
204
+ if not filename:
205
+ self.notify("Please enter a filename", severity="error")
206
+ return
207
+
208
+ # Add extension if not present
209
+ selected_format = format_select.value
210
+ if selected_format == "csv" and not filename.lower().endswith('.csv'):
211
+ filename += '.csv'
212
+ elif selected_format == "json" and not filename.lower().endswith('.json'):
213
+ filename += '.json'
214
+ elif selected_format == "markdown" and not filename.lower().endswith('.md'):
215
+ filename += '.md'
216
+
217
+ full_path = export_path / filename
218
+
219
+ # Check if file exists
220
+ if full_path.exists():
221
+ # In a real implementation, you'd show a confirmation dialog
222
+ # For now, we'll just proceed with overwrite warning
223
+ pass
224
+
225
+ # Create export configuration
226
+ export_config = {
227
+ 'format': selected_format,
228
+ 'path': full_path,
229
+ 'include_headers': include_headers.value,
230
+ 'anonymize': anonymize_data.value,
231
+ }
232
+
233
+ # Send export request message
234
+ self.post_message(self.ExportRequested(export_config))
235
+ self.dismiss(export_config)
236
+
237
+ except Exception as e:
238
+ self.notify(f"Export configuration error: {e}", severity="error")
239
+
240
+ def on_select_changed(self, event: Select.Changed) -> None:
241
+ """Handle format selection changes."""
242
+ if event.select.id == "format-select":
243
+ # Update filename extension when format changes
244
+ filename_input = self.query_one("#filename-input", Input)
245
+ current_filename = filename_input.value
246
+
247
+ # Remove existing extension
248
+ name_without_ext = current_filename.rsplit('.', 1)[0]
249
+
250
+ # Add new extension
251
+ new_format = event.value
252
+ if new_format == "csv":
253
+ new_filename = f"{name_without_ext}.csv"
254
+ elif new_format == "json":
255
+ new_filename = f"{name_without_ext}.json"
256
+ elif new_format == "markdown":
257
+ new_filename = f"{name_without_ext}.md"
258
+ else:
259
+ new_filename = name_without_ext
260
+
261
+ filename_input.value = new_filename
262
+
263
+ def validate_export_path(self, path: Path) -> tuple[bool, str]:
264
+ """
265
+ Validate export path and return validation result.
266
+
267
+ WHY: Prevents export failures by validating paths before attempting
268
+ to write files, providing clear error messages to users.
269
+
270
+ @param path: Path to validate
271
+ @return: Tuple of (is_valid, error_message)
272
+ """
273
+ try:
274
+ # Check if parent directory exists or can be created
275
+ parent_dir = path.parent
276
+ if not parent_dir.exists():
277
+ try:
278
+ parent_dir.mkdir(parents=True, exist_ok=True)
279
+ except PermissionError:
280
+ return False, f"Permission denied: Cannot create directory {parent_dir}"
281
+ except Exception as e:
282
+ return False, f"Cannot create directory {parent_dir}: {e}"
283
+
284
+ # Check write permissions
285
+ if not parent_dir.exists():
286
+ return False, f"Directory does not exist: {parent_dir}"
287
+
288
+ # Try creating a test file to check permissions
289
+ test_file = parent_dir / f".test_write_{hash(str(path))}"
290
+ try:
291
+ test_file.touch()
292
+ test_file.unlink()
293
+ except PermissionError:
294
+ return False, f"Permission denied: Cannot write to {parent_dir}"
295
+ except Exception as e:
296
+ return False, f"Cannot write to {parent_dir}: {e}"
297
+
298
+ return True, ""
299
+
300
+ except Exception as e:
301
+ return False, f"Path validation error: {e}"