gitflow-analytics 1.0.1__py3-none-any.whl → 1.3.6__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 +11 -11
- gitflow_analytics/_version.py +2 -2
- gitflow_analytics/classification/__init__.py +31 -0
- gitflow_analytics/classification/batch_classifier.py +752 -0
- gitflow_analytics/classification/classifier.py +464 -0
- gitflow_analytics/classification/feature_extractor.py +725 -0
- gitflow_analytics/classification/linguist_analyzer.py +574 -0
- gitflow_analytics/classification/model.py +455 -0
- gitflow_analytics/cli.py +4490 -378
- gitflow_analytics/cli_rich.py +503 -0
- gitflow_analytics/config/__init__.py +43 -0
- gitflow_analytics/config/errors.py +261 -0
- gitflow_analytics/config/loader.py +904 -0
- gitflow_analytics/config/profiles.py +264 -0
- gitflow_analytics/config/repository.py +124 -0
- gitflow_analytics/config/schema.py +441 -0
- gitflow_analytics/config/validator.py +154 -0
- gitflow_analytics/config.py +44 -398
- gitflow_analytics/core/analyzer.py +1320 -172
- gitflow_analytics/core/branch_mapper.py +132 -132
- gitflow_analytics/core/cache.py +1554 -175
- gitflow_analytics/core/data_fetcher.py +1193 -0
- gitflow_analytics/core/identity.py +571 -185
- gitflow_analytics/core/metrics_storage.py +526 -0
- gitflow_analytics/core/progress.py +372 -0
- gitflow_analytics/core/schema_version.py +269 -0
- gitflow_analytics/extractors/base.py +13 -11
- gitflow_analytics/extractors/ml_tickets.py +1100 -0
- gitflow_analytics/extractors/story_points.py +77 -59
- gitflow_analytics/extractors/tickets.py +841 -89
- gitflow_analytics/identity_llm/__init__.py +6 -0
- gitflow_analytics/identity_llm/analysis_pass.py +231 -0
- gitflow_analytics/identity_llm/analyzer.py +464 -0
- gitflow_analytics/identity_llm/models.py +76 -0
- gitflow_analytics/integrations/github_integration.py +258 -87
- gitflow_analytics/integrations/jira_integration.py +572 -123
- gitflow_analytics/integrations/orchestrator.py +206 -82
- gitflow_analytics/metrics/activity_scoring.py +322 -0
- gitflow_analytics/metrics/branch_health.py +470 -0
- gitflow_analytics/metrics/dora.py +542 -179
- gitflow_analytics/models/database.py +986 -59
- gitflow_analytics/pm_framework/__init__.py +115 -0
- gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
- gitflow_analytics/pm_framework/base.py +406 -0
- gitflow_analytics/pm_framework/models.py +211 -0
- gitflow_analytics/pm_framework/orchestrator.py +652 -0
- gitflow_analytics/pm_framework/registry.py +333 -0
- gitflow_analytics/qualitative/__init__.py +29 -0
- gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
- gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
- gitflow_analytics/qualitative/classifiers/change_type.py +742 -0
- gitflow_analytics/qualitative/classifiers/domain_classifier.py +506 -0
- gitflow_analytics/qualitative/classifiers/intent_analyzer.py +535 -0
- gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
- gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
- gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
- gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
- gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
- gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
- gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
- gitflow_analytics/qualitative/classifiers/risk_analyzer.py +438 -0
- gitflow_analytics/qualitative/core/__init__.py +13 -0
- gitflow_analytics/qualitative/core/llm_fallback.py +657 -0
- gitflow_analytics/qualitative/core/nlp_engine.py +382 -0
- gitflow_analytics/qualitative/core/pattern_cache.py +479 -0
- gitflow_analytics/qualitative/core/processor.py +673 -0
- gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
- gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
- gitflow_analytics/qualitative/models/__init__.py +25 -0
- gitflow_analytics/qualitative/models/schemas.py +306 -0
- gitflow_analytics/qualitative/utils/__init__.py +13 -0
- gitflow_analytics/qualitative/utils/batch_processor.py +339 -0
- gitflow_analytics/qualitative/utils/cost_tracker.py +345 -0
- gitflow_analytics/qualitative/utils/metrics.py +361 -0
- gitflow_analytics/qualitative/utils/text_processing.py +285 -0
- gitflow_analytics/reports/__init__.py +100 -0
- gitflow_analytics/reports/analytics_writer.py +550 -18
- gitflow_analytics/reports/base.py +648 -0
- gitflow_analytics/reports/branch_health_writer.py +322 -0
- gitflow_analytics/reports/classification_writer.py +924 -0
- gitflow_analytics/reports/cli_integration.py +427 -0
- gitflow_analytics/reports/csv_writer.py +1700 -216
- gitflow_analytics/reports/data_models.py +504 -0
- gitflow_analytics/reports/database_report_generator.py +427 -0
- gitflow_analytics/reports/example_usage.py +344 -0
- gitflow_analytics/reports/factory.py +499 -0
- gitflow_analytics/reports/formatters.py +698 -0
- gitflow_analytics/reports/html_generator.py +1116 -0
- gitflow_analytics/reports/interfaces.py +489 -0
- gitflow_analytics/reports/json_exporter.py +2770 -0
- gitflow_analytics/reports/narrative_writer.py +2289 -158
- gitflow_analytics/reports/story_point_correlation.py +1144 -0
- gitflow_analytics/reports/weekly_trends_writer.py +389 -0
- gitflow_analytics/training/__init__.py +5 -0
- gitflow_analytics/training/model_loader.py +377 -0
- gitflow_analytics/training/pipeline.py +550 -0
- gitflow_analytics/tui/__init__.py +5 -0
- gitflow_analytics/tui/app.py +724 -0
- gitflow_analytics/tui/screens/__init__.py +8 -0
- gitflow_analytics/tui/screens/analysis_progress_screen.py +496 -0
- gitflow_analytics/tui/screens/configuration_screen.py +523 -0
- gitflow_analytics/tui/screens/loading_screen.py +348 -0
- gitflow_analytics/tui/screens/main_screen.py +321 -0
- gitflow_analytics/tui/screens/results_screen.py +722 -0
- gitflow_analytics/tui/widgets/__init__.py +7 -0
- gitflow_analytics/tui/widgets/data_table.py +255 -0
- gitflow_analytics/tui/widgets/export_modal.py +301 -0
- gitflow_analytics/tui/widgets/progress_widget.py +187 -0
- gitflow_analytics-1.3.6.dist-info/METADATA +1015 -0
- gitflow_analytics-1.3.6.dist-info/RECORD +122 -0
- gitflow_analytics-1.0.1.dist-info/METADATA +0 -463
- gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""Common formatting utilities for report generation.
|
|
2
|
+
|
|
3
|
+
This module provides reusable formatting functions and classes
|
|
4
|
+
that can be used across different report generators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from datetime import date, datetime, timedelta
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DateFormatter:
|
|
15
|
+
"""Utilities for formatting dates in reports."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def format_date(
|
|
19
|
+
dt: Union[datetime, date, str],
|
|
20
|
+
format_string: str = "%Y-%m-%d"
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Format a date/datetime object or string.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
dt: Date/datetime object or ISO string
|
|
26
|
+
format_string: strftime format string
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Formatted date string
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(dt, str):
|
|
32
|
+
# Parse ISO format
|
|
33
|
+
if 'T' in dt:
|
|
34
|
+
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
|
35
|
+
else:
|
|
36
|
+
dt = date.fromisoformat(dt)
|
|
37
|
+
|
|
38
|
+
if isinstance(dt, datetime):
|
|
39
|
+
return dt.strftime(format_string)
|
|
40
|
+
elif isinstance(dt, date):
|
|
41
|
+
return dt.strftime(format_string)
|
|
42
|
+
else:
|
|
43
|
+
return str(dt)
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def format_date_range(
|
|
47
|
+
start: Union[datetime, date],
|
|
48
|
+
end: Union[datetime, date],
|
|
49
|
+
separator: str = " to "
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Format a date range.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
start: Start date
|
|
55
|
+
end: End date
|
|
56
|
+
separator: Separator between dates
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Formatted date range string
|
|
60
|
+
"""
|
|
61
|
+
start_str = DateFormatter.format_date(start)
|
|
62
|
+
end_str = DateFormatter.format_date(end)
|
|
63
|
+
return f"{start_str}{separator}{end_str}"
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def format_week_label(
|
|
67
|
+
week_start: Union[datetime, date],
|
|
68
|
+
include_year: bool = True
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Format a week label.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
week_start: Start date of the week
|
|
74
|
+
include_year: Whether to include the year
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Week label (e.g., "Week 23, 2024" or "Week 23")
|
|
78
|
+
"""
|
|
79
|
+
if isinstance(week_start, datetime):
|
|
80
|
+
week_start = week_start.date()
|
|
81
|
+
|
|
82
|
+
week_num = week_start.isocalendar()[1]
|
|
83
|
+
|
|
84
|
+
if include_year:
|
|
85
|
+
year = week_start.year
|
|
86
|
+
return f"Week {week_num}, {year}"
|
|
87
|
+
else:
|
|
88
|
+
return f"Week {week_num}"
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def format_duration(seconds: float, precision: int = 1) -> str:
|
|
92
|
+
"""Format a duration in seconds to human-readable format.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
seconds: Duration in seconds
|
|
96
|
+
precision: Decimal places for fractional parts
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Formatted duration string
|
|
100
|
+
"""
|
|
101
|
+
if seconds < 60:
|
|
102
|
+
return f"{seconds:.{precision}f} seconds"
|
|
103
|
+
elif seconds < 3600:
|
|
104
|
+
minutes = seconds / 60
|
|
105
|
+
return f"{minutes:.{precision}f} minutes"
|
|
106
|
+
elif seconds < 86400:
|
|
107
|
+
hours = seconds / 3600
|
|
108
|
+
return f"{hours:.{precision}f} hours"
|
|
109
|
+
else:
|
|
110
|
+
days = seconds / 86400
|
|
111
|
+
return f"{days:.{precision}f} days"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class NumberFormatter:
|
|
115
|
+
"""Utilities for formatting numbers in reports."""
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def format_integer(value: Union[int, float], thousands_sep: str = ",") -> str:
|
|
119
|
+
"""Format an integer with thousands separator.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
value: Number to format
|
|
123
|
+
thousands_sep: Thousands separator character
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Formatted number string
|
|
127
|
+
"""
|
|
128
|
+
return f"{int(value):,}".replace(",", thousands_sep)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def format_decimal(
|
|
132
|
+
value: Union[float, Decimal],
|
|
133
|
+
decimal_places: int = 2,
|
|
134
|
+
thousands_sep: str = ","
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Format a decimal number.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
value: Number to format
|
|
140
|
+
decimal_places: Number of decimal places
|
|
141
|
+
thousands_sep: Thousands separator
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Formatted number string
|
|
145
|
+
"""
|
|
146
|
+
formatted = f"{value:,.{decimal_places}f}"
|
|
147
|
+
if thousands_sep != ",":
|
|
148
|
+
formatted = formatted.replace(",", thousands_sep)
|
|
149
|
+
return formatted
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def format_percentage(
|
|
153
|
+
value: float,
|
|
154
|
+
decimal_places: int = 1,
|
|
155
|
+
include_sign: bool = True
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Format a percentage value.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
value: Percentage value (0.5 = 50%)
|
|
161
|
+
decimal_places: Number of decimal places
|
|
162
|
+
include_sign: Whether to include % sign
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Formatted percentage string
|
|
166
|
+
"""
|
|
167
|
+
percentage = value * 100
|
|
168
|
+
formatted = f"{percentage:.{decimal_places}f}"
|
|
169
|
+
|
|
170
|
+
if include_sign:
|
|
171
|
+
formatted += "%"
|
|
172
|
+
|
|
173
|
+
return formatted
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def format_bytes(
|
|
177
|
+
size_bytes: int,
|
|
178
|
+
decimal_places: int = 2
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Format byte size to human-readable format.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
size_bytes: Size in bytes
|
|
184
|
+
decimal_places: Number of decimal places
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Formatted size string
|
|
188
|
+
"""
|
|
189
|
+
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
190
|
+
if size_bytes < 1024.0:
|
|
191
|
+
if unit == 'B':
|
|
192
|
+
return f"{size_bytes} {unit}"
|
|
193
|
+
else:
|
|
194
|
+
return f"{size_bytes:.{decimal_places}f} {unit}"
|
|
195
|
+
size_bytes /= 1024.0
|
|
196
|
+
|
|
197
|
+
return f"{size_bytes:.{decimal_places}f} PB"
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def format_change(
|
|
201
|
+
value: float,
|
|
202
|
+
decimal_places: int = 1,
|
|
203
|
+
include_sign: bool = True,
|
|
204
|
+
positive_prefix: str = "+"
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Format a change value with sign.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
value: Change value
|
|
210
|
+
decimal_places: Number of decimal places
|
|
211
|
+
include_sign: Whether to include +/- sign
|
|
212
|
+
positive_prefix: Prefix for positive values
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Formatted change string
|
|
216
|
+
"""
|
|
217
|
+
formatted = f"{abs(value):.{decimal_places}f}"
|
|
218
|
+
|
|
219
|
+
if include_sign:
|
|
220
|
+
if value > 0:
|
|
221
|
+
formatted = f"{positive_prefix}{formatted}"
|
|
222
|
+
elif value < 0:
|
|
223
|
+
formatted = f"-{formatted}"
|
|
224
|
+
|
|
225
|
+
return formatted
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TextFormatter:
|
|
229
|
+
"""Utilities for formatting text in reports."""
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def truncate(
|
|
233
|
+
text: str,
|
|
234
|
+
max_length: int,
|
|
235
|
+
suffix: str = "..."
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Truncate text to maximum length.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
text: Text to truncate
|
|
241
|
+
max_length: Maximum length
|
|
242
|
+
suffix: Suffix to add when truncated
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Truncated text
|
|
246
|
+
"""
|
|
247
|
+
if len(text) <= max_length:
|
|
248
|
+
return text
|
|
249
|
+
|
|
250
|
+
truncate_at = max_length - len(suffix)
|
|
251
|
+
return text[:truncate_at] + suffix
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def wrap_text(
|
|
255
|
+
text: str,
|
|
256
|
+
width: int = 80,
|
|
257
|
+
indent: str = ""
|
|
258
|
+
) -> str:
|
|
259
|
+
"""Wrap text to specified width.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
text: Text to wrap
|
|
263
|
+
width: Maximum line width
|
|
264
|
+
indent: Indentation for wrapped lines
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Wrapped text
|
|
268
|
+
"""
|
|
269
|
+
import textwrap
|
|
270
|
+
return textwrap.fill(text, width=width, subsequent_indent=indent)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def sanitize_filename(
|
|
274
|
+
filename: str,
|
|
275
|
+
replacement: str = "_"
|
|
276
|
+
) -> str:
|
|
277
|
+
"""Sanitize a filename by removing invalid characters.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
filename: Filename to sanitize
|
|
281
|
+
replacement: Replacement for invalid characters
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Sanitized filename
|
|
285
|
+
"""
|
|
286
|
+
# Remove invalid characters
|
|
287
|
+
invalid_chars = r'[<>:"/\\|?*]'
|
|
288
|
+
sanitized = re.sub(invalid_chars, replacement, filename)
|
|
289
|
+
|
|
290
|
+
# Remove leading/trailing dots and spaces
|
|
291
|
+
sanitized = sanitized.strip(". ")
|
|
292
|
+
|
|
293
|
+
# Limit length
|
|
294
|
+
max_length = 255
|
|
295
|
+
if len(sanitized) > max_length:
|
|
296
|
+
name, ext = os.path.splitext(sanitized)
|
|
297
|
+
name = name[:max_length - len(ext) - 1]
|
|
298
|
+
sanitized = name + ext
|
|
299
|
+
|
|
300
|
+
return sanitized
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def anonymize_email(email: str) -> str:
|
|
304
|
+
"""Anonymize an email address.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
email: Email address to anonymize
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Anonymized email
|
|
311
|
+
"""
|
|
312
|
+
if '@' not in email:
|
|
313
|
+
return email
|
|
314
|
+
|
|
315
|
+
local, domain = email.split('@', 1)
|
|
316
|
+
|
|
317
|
+
if len(local) <= 3:
|
|
318
|
+
anonymized_local = '*' * len(local)
|
|
319
|
+
else:
|
|
320
|
+
anonymized_local = local[0] + '*' * (len(local) - 2) + local[-1]
|
|
321
|
+
|
|
322
|
+
return f"{anonymized_local}@{domain}"
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def format_list(
|
|
326
|
+
items: List[str],
|
|
327
|
+
separator: str = ", ",
|
|
328
|
+
last_separator: str = " and ",
|
|
329
|
+
max_items: Optional[int] = None
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Format a list of items as text.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
items: List of items
|
|
335
|
+
separator: Separator between items
|
|
336
|
+
last_separator: Separator before last item
|
|
337
|
+
max_items: Maximum items to show
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Formatted list string
|
|
341
|
+
"""
|
|
342
|
+
if not items:
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
if max_items and len(items) > max_items:
|
|
346
|
+
shown = items[:max_items]
|
|
347
|
+
remaining = len(items) - max_items
|
|
348
|
+
shown.append(f"and {remaining} more")
|
|
349
|
+
items = shown
|
|
350
|
+
|
|
351
|
+
if len(items) == 1:
|
|
352
|
+
return items[0]
|
|
353
|
+
elif len(items) == 2:
|
|
354
|
+
return f"{items[0]}{last_separator}{items[1]}"
|
|
355
|
+
else:
|
|
356
|
+
return separator.join(items[:-1]) + last_separator + items[-1]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class MarkdownFormatter:
|
|
360
|
+
"""Utilities for formatting Markdown content."""
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def header(text: str, level: int = 1) -> str:
|
|
364
|
+
"""Create a Markdown header.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
text: Header text
|
|
368
|
+
level: Header level (1-6)
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Markdown header
|
|
372
|
+
"""
|
|
373
|
+
if level < 1 or level > 6:
|
|
374
|
+
level = 1
|
|
375
|
+
return f"{'#' * level} {text}"
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def bold(text: str) -> str:
|
|
379
|
+
"""Format text as bold.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
text: Text to format
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Bold Markdown text
|
|
386
|
+
"""
|
|
387
|
+
return f"**{text}**"
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def italic(text: str) -> str:
|
|
391
|
+
"""Format text as italic.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
text: Text to format
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Italic Markdown text
|
|
398
|
+
"""
|
|
399
|
+
return f"*{text}*"
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def code(text: str, language: Optional[str] = None) -> str:
|
|
403
|
+
"""Format text as code.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
text: Code text
|
|
407
|
+
language: Optional language for syntax highlighting
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Code Markdown text
|
|
411
|
+
"""
|
|
412
|
+
if '\n' in text:
|
|
413
|
+
# Code block
|
|
414
|
+
if language:
|
|
415
|
+
return f"```{language}\n{text}\n```"
|
|
416
|
+
else:
|
|
417
|
+
return f"```\n{text}\n```"
|
|
418
|
+
else:
|
|
419
|
+
# Inline code
|
|
420
|
+
return f"`{text}`"
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def link(text: str, url: str, title: Optional[str] = None) -> str:
|
|
424
|
+
"""Create a Markdown link.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
text: Link text
|
|
428
|
+
url: Link URL
|
|
429
|
+
title: Optional link title
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Markdown link
|
|
433
|
+
"""
|
|
434
|
+
if title:
|
|
435
|
+
return f'[{text}]({url} "{title}")'
|
|
436
|
+
else:
|
|
437
|
+
return f'[{text}]({url})'
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def list_item(text: str, level: int = 0, ordered: bool = False) -> str:
|
|
441
|
+
"""Create a list item.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
text: Item text
|
|
445
|
+
level: Indentation level
|
|
446
|
+
ordered: Whether this is an ordered list
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
Markdown list item
|
|
450
|
+
"""
|
|
451
|
+
indent = " " * level
|
|
452
|
+
marker = "1." if ordered else "-"
|
|
453
|
+
return f"{indent}{marker} {text}"
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def table(
|
|
457
|
+
headers: List[str],
|
|
458
|
+
rows: List[List[str]],
|
|
459
|
+
alignment: Optional[List[str]] = None
|
|
460
|
+
) -> str:
|
|
461
|
+
"""Create a Markdown table.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
headers: Table headers
|
|
465
|
+
rows: Table rows
|
|
466
|
+
alignment: Column alignment ('left', 'center', 'right')
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Markdown table
|
|
470
|
+
"""
|
|
471
|
+
if not headers or not rows:
|
|
472
|
+
return ""
|
|
473
|
+
|
|
474
|
+
# Build header row
|
|
475
|
+
header_row = "| " + " | ".join(headers) + " |"
|
|
476
|
+
|
|
477
|
+
# Build separator row
|
|
478
|
+
if alignment:
|
|
479
|
+
separators = []
|
|
480
|
+
for align in alignment:
|
|
481
|
+
if align == 'center':
|
|
482
|
+
separators.append(':---:')
|
|
483
|
+
elif align == 'right':
|
|
484
|
+
separators.append('---:')
|
|
485
|
+
else:
|
|
486
|
+
separators.append('---')
|
|
487
|
+
else:
|
|
488
|
+
separators = ['---'] * len(headers)
|
|
489
|
+
|
|
490
|
+
separator_row = "| " + " | ".join(separators) + " |"
|
|
491
|
+
|
|
492
|
+
# Build data rows
|
|
493
|
+
data_rows = []
|
|
494
|
+
for row in rows:
|
|
495
|
+
# Ensure row has correct number of columns
|
|
496
|
+
while len(row) < len(headers):
|
|
497
|
+
row.append("")
|
|
498
|
+
row = row[:len(headers)]
|
|
499
|
+
|
|
500
|
+
data_rows.append("| " + " | ".join(str(cell) for cell in row) + " |")
|
|
501
|
+
|
|
502
|
+
# Combine all parts
|
|
503
|
+
table_parts = [header_row, separator_row] + data_rows
|
|
504
|
+
return "\n".join(table_parts)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class CSVFormatter:
|
|
508
|
+
"""Utilities for formatting CSV content."""
|
|
509
|
+
|
|
510
|
+
@staticmethod
|
|
511
|
+
def escape_value(value: Any) -> str:
|
|
512
|
+
"""Escape a value for CSV output.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
value: Value to escape
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Escaped string
|
|
519
|
+
"""
|
|
520
|
+
if value is None:
|
|
521
|
+
return ""
|
|
522
|
+
|
|
523
|
+
str_value = str(value)
|
|
524
|
+
|
|
525
|
+
# Check if escaping is needed
|
|
526
|
+
if any(char in str_value for char in [',', '"', '\n', '\r']):
|
|
527
|
+
# Escape quotes
|
|
528
|
+
str_value = str_value.replace('"', '""')
|
|
529
|
+
# Wrap in quotes
|
|
530
|
+
str_value = f'"{str_value}"'
|
|
531
|
+
|
|
532
|
+
return str_value
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def format_row(values: List[Any], delimiter: str = ",") -> str:
|
|
536
|
+
"""Format a row of values as CSV.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
values: Row values
|
|
540
|
+
delimiter: CSV delimiter
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
CSV row string
|
|
544
|
+
"""
|
|
545
|
+
escaped = [CSVFormatter.escape_value(v) for v in values]
|
|
546
|
+
return delimiter.join(escaped)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class JSONFormatter:
|
|
550
|
+
"""Utilities for formatting JSON content."""
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def format_json(
|
|
554
|
+
data: Any,
|
|
555
|
+
indent: int = 2,
|
|
556
|
+
sort_keys: bool = False,
|
|
557
|
+
ensure_ascii: bool = False
|
|
558
|
+
) -> str:
|
|
559
|
+
"""Format data as JSON.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
data: Data to format
|
|
563
|
+
indent: Indentation level
|
|
564
|
+
sort_keys: Whether to sort object keys
|
|
565
|
+
ensure_ascii: Whether to escape non-ASCII characters
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
JSON string
|
|
569
|
+
"""
|
|
570
|
+
def json_serializer(obj):
|
|
571
|
+
"""Custom JSON serializer for special types."""
|
|
572
|
+
if isinstance(obj, (datetime, date)):
|
|
573
|
+
return obj.isoformat()
|
|
574
|
+
elif isinstance(obj, Decimal):
|
|
575
|
+
return float(obj)
|
|
576
|
+
elif hasattr(obj, '__dict__'):
|
|
577
|
+
return obj.__dict__
|
|
578
|
+
else:
|
|
579
|
+
return str(obj)
|
|
580
|
+
|
|
581
|
+
return json.dumps(
|
|
582
|
+
data,
|
|
583
|
+
indent=indent,
|
|
584
|
+
sort_keys=sort_keys,
|
|
585
|
+
ensure_ascii=ensure_ascii,
|
|
586
|
+
default=json_serializer
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
@staticmethod
|
|
590
|
+
def minify_json(json_str: str) -> str:
|
|
591
|
+
"""Minify JSON string.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
json_str: JSON string to minify
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Minified JSON string
|
|
598
|
+
"""
|
|
599
|
+
data = json.loads(json_str)
|
|
600
|
+
return json.dumps(data, separators=(',', ':'))
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
class MetricFormatter:
|
|
604
|
+
"""Utilities for formatting metrics in reports."""
|
|
605
|
+
|
|
606
|
+
@staticmethod
|
|
607
|
+
def format_commit_count(count: int) -> str:
|
|
608
|
+
"""Format commit count.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
count: Number of commits
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
Formatted string
|
|
615
|
+
"""
|
|
616
|
+
if count == 0:
|
|
617
|
+
return "No commits"
|
|
618
|
+
elif count == 1:
|
|
619
|
+
return "1 commit"
|
|
620
|
+
else:
|
|
621
|
+
return f"{NumberFormatter.format_integer(count)} commits"
|
|
622
|
+
|
|
623
|
+
@staticmethod
|
|
624
|
+
def format_line_changes(
|
|
625
|
+
additions: int,
|
|
626
|
+
deletions: int,
|
|
627
|
+
net: bool = False
|
|
628
|
+
) -> str:
|
|
629
|
+
"""Format line change statistics.
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
additions: Number of additions
|
|
633
|
+
deletions: Number of deletions
|
|
634
|
+
net: Whether to show net change
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
Formatted string
|
|
638
|
+
"""
|
|
639
|
+
add_str = f"+{NumberFormatter.format_integer(additions)}"
|
|
640
|
+
del_str = f"-{NumberFormatter.format_integer(deletions)}"
|
|
641
|
+
|
|
642
|
+
if net:
|
|
643
|
+
net_change = additions - deletions
|
|
644
|
+
net_str = NumberFormatter.format_change(net_change, decimal_places=0)
|
|
645
|
+
return f"{add_str} / {del_str} (net: {net_str})"
|
|
646
|
+
else:
|
|
647
|
+
return f"{add_str} / {del_str}"
|
|
648
|
+
|
|
649
|
+
@staticmethod
|
|
650
|
+
def format_velocity(
|
|
651
|
+
value: float,
|
|
652
|
+
unit: str = "commits/week"
|
|
653
|
+
) -> str:
|
|
654
|
+
"""Format velocity metric.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
value: Velocity value
|
|
658
|
+
unit: Unit of measurement
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Formatted string
|
|
662
|
+
"""
|
|
663
|
+
return f"{NumberFormatter.format_decimal(value, 1)} {unit}"
|
|
664
|
+
|
|
665
|
+
@staticmethod
|
|
666
|
+
def format_score(
|
|
667
|
+
score: float,
|
|
668
|
+
max_score: float = 100,
|
|
669
|
+
include_grade: bool = True
|
|
670
|
+
) -> str:
|
|
671
|
+
"""Format a score value.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
score: Score value
|
|
675
|
+
max_score: Maximum possible score
|
|
676
|
+
include_grade: Whether to include letter grade
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Formatted string
|
|
680
|
+
"""
|
|
681
|
+
percentage = (score / max_score) * 100
|
|
682
|
+
formatted = f"{NumberFormatter.format_decimal(score, 1)}/{max_score}"
|
|
683
|
+
|
|
684
|
+
if include_grade:
|
|
685
|
+
if percentage >= 90:
|
|
686
|
+
grade = "A"
|
|
687
|
+
elif percentage >= 80:
|
|
688
|
+
grade = "B"
|
|
689
|
+
elif percentage >= 70:
|
|
690
|
+
grade = "C"
|
|
691
|
+
elif percentage >= 60:
|
|
692
|
+
grade = "D"
|
|
693
|
+
else:
|
|
694
|
+
grade = "F"
|
|
695
|
+
|
|
696
|
+
formatted += f" ({grade})"
|
|
697
|
+
|
|
698
|
+
return formatted
|