gitflow-analytics 1.0.3__py3-none-any.whl → 1.3.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. gitflow_analytics/_version.py +1 -1
  2. gitflow_analytics/classification/__init__.py +31 -0
  3. gitflow_analytics/classification/batch_classifier.py +752 -0
  4. gitflow_analytics/classification/classifier.py +464 -0
  5. gitflow_analytics/classification/feature_extractor.py +725 -0
  6. gitflow_analytics/classification/linguist_analyzer.py +574 -0
  7. gitflow_analytics/classification/model.py +455 -0
  8. gitflow_analytics/cli.py +4158 -350
  9. gitflow_analytics/cli_rich.py +198 -48
  10. gitflow_analytics/config/__init__.py +43 -0
  11. gitflow_analytics/config/errors.py +261 -0
  12. gitflow_analytics/config/loader.py +905 -0
  13. gitflow_analytics/config/profiles.py +264 -0
  14. gitflow_analytics/config/repository.py +124 -0
  15. gitflow_analytics/config/schema.py +444 -0
  16. gitflow_analytics/config/validator.py +154 -0
  17. gitflow_analytics/config.py +44 -508
  18. gitflow_analytics/core/analyzer.py +1209 -98
  19. gitflow_analytics/core/cache.py +1337 -29
  20. gitflow_analytics/core/data_fetcher.py +1285 -0
  21. gitflow_analytics/core/identity.py +363 -14
  22. gitflow_analytics/core/metrics_storage.py +526 -0
  23. gitflow_analytics/core/progress.py +372 -0
  24. gitflow_analytics/core/schema_version.py +269 -0
  25. gitflow_analytics/extractors/ml_tickets.py +1100 -0
  26. gitflow_analytics/extractors/story_points.py +8 -1
  27. gitflow_analytics/extractors/tickets.py +749 -11
  28. gitflow_analytics/identity_llm/__init__.py +6 -0
  29. gitflow_analytics/identity_llm/analysis_pass.py +231 -0
  30. gitflow_analytics/identity_llm/analyzer.py +464 -0
  31. gitflow_analytics/identity_llm/models.py +76 -0
  32. gitflow_analytics/integrations/github_integration.py +175 -11
  33. gitflow_analytics/integrations/jira_integration.py +461 -24
  34. gitflow_analytics/integrations/orchestrator.py +124 -1
  35. gitflow_analytics/metrics/activity_scoring.py +322 -0
  36. gitflow_analytics/metrics/branch_health.py +470 -0
  37. gitflow_analytics/metrics/dora.py +379 -20
  38. gitflow_analytics/models/database.py +843 -53
  39. gitflow_analytics/pm_framework/__init__.py +115 -0
  40. gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
  41. gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
  42. gitflow_analytics/pm_framework/base.py +406 -0
  43. gitflow_analytics/pm_framework/models.py +211 -0
  44. gitflow_analytics/pm_framework/orchestrator.py +652 -0
  45. gitflow_analytics/pm_framework/registry.py +333 -0
  46. gitflow_analytics/qualitative/__init__.py +9 -10
  47. gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
  48. gitflow_analytics/qualitative/classifiers/__init__.py +3 -3
  49. gitflow_analytics/qualitative/classifiers/change_type.py +518 -244
  50. gitflow_analytics/qualitative/classifiers/domain_classifier.py +272 -165
  51. gitflow_analytics/qualitative/classifiers/intent_analyzer.py +321 -222
  52. gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
  53. gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
  54. gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
  55. gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
  56. gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
  57. gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
  58. gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
  59. gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
  60. gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
  61. gitflow_analytics/qualitative/classifiers/risk_analyzer.py +215 -189
  62. gitflow_analytics/qualitative/core/__init__.py +4 -4
  63. gitflow_analytics/qualitative/core/llm_fallback.py +239 -235
  64. gitflow_analytics/qualitative/core/nlp_engine.py +157 -148
  65. gitflow_analytics/qualitative/core/pattern_cache.py +214 -192
  66. gitflow_analytics/qualitative/core/processor.py +381 -248
  67. gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
  68. gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
  69. gitflow_analytics/qualitative/models/__init__.py +7 -7
  70. gitflow_analytics/qualitative/models/schemas.py +155 -121
  71. gitflow_analytics/qualitative/utils/__init__.py +4 -4
  72. gitflow_analytics/qualitative/utils/batch_processor.py +136 -123
  73. gitflow_analytics/qualitative/utils/cost_tracker.py +142 -140
  74. gitflow_analytics/qualitative/utils/metrics.py +172 -158
  75. gitflow_analytics/qualitative/utils/text_processing.py +146 -104
  76. gitflow_analytics/reports/__init__.py +100 -0
  77. gitflow_analytics/reports/analytics_writer.py +539 -14
  78. gitflow_analytics/reports/base.py +648 -0
  79. gitflow_analytics/reports/branch_health_writer.py +322 -0
  80. gitflow_analytics/reports/classification_writer.py +924 -0
  81. gitflow_analytics/reports/cli_integration.py +427 -0
  82. gitflow_analytics/reports/csv_writer.py +1676 -212
  83. gitflow_analytics/reports/data_models.py +504 -0
  84. gitflow_analytics/reports/database_report_generator.py +427 -0
  85. gitflow_analytics/reports/example_usage.py +344 -0
  86. gitflow_analytics/reports/factory.py +499 -0
  87. gitflow_analytics/reports/formatters.py +698 -0
  88. gitflow_analytics/reports/html_generator.py +1116 -0
  89. gitflow_analytics/reports/interfaces.py +489 -0
  90. gitflow_analytics/reports/json_exporter.py +2770 -0
  91. gitflow_analytics/reports/narrative_writer.py +2287 -158
  92. gitflow_analytics/reports/story_point_correlation.py +1144 -0
  93. gitflow_analytics/reports/weekly_trends_writer.py +389 -0
  94. gitflow_analytics/training/__init__.py +5 -0
  95. gitflow_analytics/training/model_loader.py +377 -0
  96. gitflow_analytics/training/pipeline.py +550 -0
  97. gitflow_analytics/tui/__init__.py +1 -1
  98. gitflow_analytics/tui/app.py +129 -126
  99. gitflow_analytics/tui/screens/__init__.py +3 -3
  100. gitflow_analytics/tui/screens/analysis_progress_screen.py +188 -179
  101. gitflow_analytics/tui/screens/configuration_screen.py +154 -178
  102. gitflow_analytics/tui/screens/loading_screen.py +100 -110
  103. gitflow_analytics/tui/screens/main_screen.py +89 -72
  104. gitflow_analytics/tui/screens/results_screen.py +305 -281
  105. gitflow_analytics/tui/widgets/__init__.py +2 -2
  106. gitflow_analytics/tui/widgets/data_table.py +67 -69
  107. gitflow_analytics/tui/widgets/export_modal.py +76 -76
  108. gitflow_analytics/tui/widgets/progress_widget.py +41 -46
  109. gitflow_analytics-1.3.11.dist-info/METADATA +1015 -0
  110. gitflow_analytics-1.3.11.dist-info/RECORD +122 -0
  111. gitflow_analytics-1.0.3.dist-info/METADATA +0 -490
  112. gitflow_analytics-1.0.3.dist-info/RECORD +0 -62
  113. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/WHEEL +0 -0
  114. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/entry_points.txt +0 -0
  115. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/licenses/LICENSE +0 -0
  116. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.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